Machine Learning desde cero en C++
Regresión lineal con gradiente descendente, sin bibliotecas y con las cuentas a la vista
Un modelo lineal se puede entrenar con treinta líneas de C++ y un bucle. Aquí calculas el error cuadrático medio, derivas sus gradientes, los aplicas paso a paso con números verificables y entrenas una regresión completa hasta que la predicción coincide con la recta que generó los datos.
- Calcular el error cuadrático medio de un modelo lineal
- Derivar e implementar los gradientes de la pérdida respecto de w y b
- Ejecutar a mano un paso de gradiente descendente y comprobarlo con el programa
- Entrenar una regresión hasta que converja y predecir valores nuevos
- Explicar qué papel juegan la tasa de aprendizaje y el escalado de las entradas
Antes necesitas: Arrays, std::vector y std::string · Funciones: declaración, parámetros y retorno · Eigen: álgebra lineal en C++
En esta lección
- ¿Qué es entrenar un modelo?
- ¿Para qué sirve hacerlo en C++?
- Cómo funciona por dentro: modelo, pérdida y gradiente
- Sintaxis: la función de pérdida
- Ejemplo intermedio: un paso de gradiente a mano
- Visualización: X, predicción y error época a época
- Caso práctico: quinientas épocas de entrenamiento
- Vectorización y comparación con Python
- Errores comunes
- Buenas prácticas
- Ejercicios
- Soluciones explicadas
- Resumen
- Evaluación
Un modelo de Machine Learning no es magia: es una función con parámetros que se ajustan para que el error sea pequeño. En esta lección construyes la regresión lineal más simple posible en C++ puro, sin bibliotecas, y sigues cada número a mano. Al terminar sabrás qué hay dentro de una línea de model.fit() y podrás comprobarlo con una calculadora.
¿Qué es entrenar un modelo?
Entrenar es buscar los parámetros que hacen mínima una medida de error. En regresión lineal el modelo es y = w·x + b, donde w es la pendiente y b el término independiente. Entrenar consiste en empezar con valores cualesquiera, medir cuánto se equivoca el modelo y corregir los parámetros en la dirección que reduce el error.
- Modelo: la forma de la función. Aquí una recta con dos parámetros.
- Pérdida: un número que mide el error. Aquí el error cuadrático medio.
- Gradiente: la derivada de la pérdida respecto de cada parámetro; indica hacia dónde crece el error.
- Tasa de aprendizaje: cuánto avanzas en esa dirección en cada paso.
¿Para qué sirve hacerlo en C++?
En Python escribirías cuatro líneas con NumPy y funcionaría. La razón para bajarlo a C++ es la misma por la que existen PyTorch y TensorFlow por debajo: controlar la memoria, evitar la sobrecarga del intérprete y poder ejecutar el modelo donde no hay Python, como un móvil, un microcontrolador o un servicio con latencia de microsegundos.
- Entender el algoritmo: al escribirlo sin bibliotecas, cada derivada deja de ser una caja negra.
- Inferencia embebida: un modelo lineal entrenado ocupa dos
doubley se evalúa en nanosegundos. - Prototipos verificables: puedes comparar tu implementación con la de una biblioteca línea por línea.
- Base para modelos mayores: la misma estructura sirve para regresión logística o una red pequeña.
Cómo funciona por dentro: modelo, pérdida y gradiente
Para cada muestra i el modelo predice ŷ_i = w·x_i + b. El error de esa muestra es ŷ_i − y_i, y la pérdida promedia sus cuadrados: MSE = (1/n) · Σ (ŷ_i − y_i)². Se eleva al cuadrado para que los errores positivos y negativos no se cancelen y para castigar más los grandes.
La derivada de esa pérdida respecto de cada parámetro tiene una forma cerrada. Respecto de la pendiente: ∂MSE/∂w = (2/n) · Σ x_i · (ŷ_i − y_i). Respecto del término independiente: ∂MSE/∂b = (2/n) · Σ (ŷ_i − y_i). No hace falta memorizarlas: se obtienen aplicando la regla de la cadena al cuadrado.
El gradiente apunta hacia donde la pérdida crece. Por eso la actualización resta: w ← w − lr · ∂MSE/∂w, y lo mismo con b. Si el gradiente es negativo, restar un negativo suma, y el parámetro se mueve en la dirección que baja el error.
Sintaxis: la función de pérdida
Empieza por la pieza más pequeña: una función que recibe los datos, la pendiente y el término independiente, y devuelve un solo número. Es la que te dirá si el entrenamiento mejora o empeora.
#include <cstdio>
#include <vector>
double mse(const std::vector<double>& x, const std::vector<double>& y, double w, double b) {
double suma = 0.0;
for (std::size_t i = 0; i < x.size(); ++i) {
const double error = w * x[i] + b - y[i];
suma += error * error;
}
return suma / static_cast<double>(x.size());
}
int main() {
const std::vector<double> x{1.0, 2.0, 3.0, 4.0};
const std::vector<double> y{2.0, 4.0, 6.0, 8.0};
std::printf("w=0.0 b=0.0 -> %.6f\n", mse(x, y, 0.0, 0.0));
std::printf("w=1.5 b=0.5 -> %.6f\n", mse(x, y, 1.5, 0.5));
std::printf("w=1.75 b=0.575 -> %.6f\n", mse(x, y, 1.75, 0.575));
std::printf("w=2.0 b=0.0 -> %.6f\n", mse(x, y, 2.0, 0.0));
return 0;
}Los cuatro resultados son 30.000000, 0.875000, 0.080625 y 0.000000. El último confirma la respuesta: los datos salen de la recta y = 2x, así que w = 2 y b = 0 dejan error cero. Los demás son los pasos intermedios que el entrenamiento irá recorriendo.
Ejemplo intermedio: un paso de gradiente a mano
Empieza con w = 0 y b = 0. Las predicciones son todas cero, así que los errores son -2, -4, -6, -8. Sus cuadrados suman 4 + 16 + 36 + 64 = 120, y al dividir entre cuatro muestras la pérdida es 30.
| x | y | ŷ = 0 | error = ŷ − y | error² |
|---|---|---|---|---|
| 1 | 2 | 0 | -2 | 4 |
| 2 | 4 | 0 | -4 | 16 |
| 3 | 6 | 0 | -6 | 36 |
| 4 | 8 | 0 | -8 | 64 |
| Σ | — | — | -20 | 120 |
Ahora los gradientes. Para w: (2/4) · (1·(-2) + 2·(-4) + 3·(-6) + 4·(-8)) = 0.5 · (-60) = -30. Para b: (2/4) · (-20) = -10. Ambos son negativos, así que restarlos aumenta los parámetros: la recta está demasiado plana y demasiado baja.
Con lr = 0.05: w = 0 − 0.05 · (-30) = 1.5 y b = 0 − 0.05 · (-10) = 0.5. Un solo paso ya llevó la pérdida de 30 a 0.875. El segundo paso, calculado igual, da w = 1.75 y b = 0.575, con pérdida 0.080625. El programa de la sección siguiente reproduce exactamente esos números.
Visualización: X, predicción y error época a época
Cada fila es una muestra y cada columna un vector: la entrada, la respuesta correcta, la predicción y el error. El visor redondea a dos decimales, así que los valores exactos aparecen en las notas de cada paso.
w = 0 y b = 0: todas las predicciones son cero y los errores son -y. MSE = 30.Recorre los pasos para ver cómo cambian las celdas.
Fíjate en la forma de los errores: en la época 10 el modelo sobreestima las muestras pequeñas (+0.35 en x = 1) y subestima las grandes (-0.19 en x = 4), señal de que la recta está girada y le falta pendiente. La pérdida cae de 30 a 0.046 en diez épocas y después avanza mucho más despacio: hacen falta otras 490 para llegar prácticamente a cero.
Caso práctico: quinientas épocas de entrenamiento
Este es el programa completo. Calcula la pérdida con los parámetros actuales, obtiene los dos gradientes y actualiza. La impresión se hace después de actualizar, así que w, b y MSE de cada línea corresponden al mismo modelo.
#include <cstdio>
#include <vector>
double mse(const std::vector<double>& x, const std::vector<double>& y, double w, double b) {
double suma = 0.0;
for (std::size_t i = 0; i < x.size(); ++i) {
const double error = w * x[i] + b - y[i];
suma += error * error;
}
return suma / static_cast<double>(x.size());
}
int main() {
const std::vector<double> x{1.0, 2.0, 3.0, 4.0};
const std::vector<double> y{2.0, 4.0, 6.0, 8.0};
const std::size_t n = x.size();
double w = 0.0;
double b = 0.0;
const double lr = 0.05;
for (int epoca = 1; epoca <= 500; ++epoca) {
double gw = 0.0;
double gb = 0.0;
for (std::size_t i = 0; i < n; ++i) {
const double error = w * x[i] + b - y[i];
gw += 2.0 * x[i] * error / static_cast<double>(n);
gb += 2.0 * error / static_cast<double>(n);
}
w -= lr * gw;
b -= lr * gb;
if (epoca == 1 || epoca == 2 || epoca == 3 || epoca == 10 || epoca == 500) {
std::printf("epoca %3d w=%.6f b=%.6f MSE=%.8f\n", epoca, w, b, mse(x, y, w, b));
}
}
std::printf("prediccion en x=5: %.6f\n", w * 5.0 + b);
return 0;
}| Época | w | b | MSE |
|---|---|---|---|
| 1 | 1.500000 | 0.500000 | 0.87500000 |
| 2 | 1.750000 | 0.575000 | 0.08062500 |
| 3 | 1.793750 | 0.580000 | 0.05731797 |
| 10 | 1.821657 | 0.524349 | 0.04591871 |
| 500 | 1.999890 | 0.000324 | 0.00000002 |
La predicción para x = 5 es 9.999773, frente al valor exacto 10. El modelo aprendió la recta con un error de 0.000227. Observa también la pérdida: baja muy rápido al principio y luego avanza a trompicones, porque los dos parámetros no son independientes cuando x no está centrada.
Vectorización y comparación con Python
El bucle interno hace el mismo trabajo que una operación vectorizada: multiplicar un vector por un escalar, restar otro vector y promediar. La versión de Python lo expresa en dos líneas; la de C++ deja ver cada operación y permite cambiar el tipo de dato o paralelizarla.
import numpy as np
x = np.array([1.0, 2.0, 3.0, 4.0])
y = np.array([2.0, 4.0, 6.0, 8.0])
w, b, lr = 0.0, 0.0, 0.05
for _ in range(500):
error = (w * x + b) - y
w -= lr * 2 * np.mean(x * error)
b -= lr * 2 * np.mean(error)
print(w, b) # 2.0 0.0
print(w * 5 + b) # 10.0for (int epoca = 1; epoca <= 500; ++epoca) {
double gw = 0.0, gb = 0.0;
for (std::size_t i = 0; i < n; ++i) {
const double error = w * x[i] + b - y[i];
gw += 2.0 * x[i] * error / n;
gb += 2.0 * error / n;
}
w -= lr * gw;
b -= lr * gb;
}`np.mean(x * error)` es exactamente `Σ x_i · e_i / n`: la misma suma que el bucle. La diferencia no es matemática sino de expresión: NumPy oculta el recorrido y C++ lo muestra. Con cuatro muestras da igual; con cien millones, la versión de NumPy delega en C y la tuya compite con ella.
Si traduces el modelo a producción, esa traducción es literal: el modelo entrenado son dos números. Guardarlos en un archivo y evaluar w * x + b en el servidor cuesta menos que importar una biblioteca de aprendizaje automático.
Errores comunes
- Signo del gradiente: sumar en lugar de restar hace que la pérdida crezca en cada época. Si la pérdida aumenta desde el primer paso, revisa el signo antes que nada.
- Tasa demasiado grande: con
lralta la pérdida oscila y puede llegar a infinito. Prueba valores como 0.01, 0.05 y 0.1 y mira la primera época. - Olvidar dividir entre
n: sin el promedio, el gradiente escala con el número de muestras y la tasa efectiva cambia según el dataset. - Entrenar y evaluar con los mismos datos sin separar: la pérdida de entrenamiento no dice si el modelo generaliza.
- Comparar con
==: la pérdida nunca llega a cero exacto con datos reales; usa tolerancias.
Buenas prácticas
- Centra y escala las entradas antes de entrenar: acelera la convergencia y evita que la tasa dependa de las unidades.
- Guarda la historia de la pérdida por época y revísala; una curva plana o creciente indica un problema de tasa.
- Usa nombres que digan qué son:
gw,gb,lr,epoca, noa,b,c. - Fija una semilla si en algún momento añades aleatoriedad al inicializar.
- Compara tu implementación con una biblioteca conocida usando los mismos datos y la misma pérdida.
- Con datos reales, divide en entrenamiento y prueba antes de creer cualquier cifra de error.
Ejercicios
Dos ejercicios sobre el mismo programa: uno para afianzar la pérdida y otro para añadir una condición de parada real.
Comprobar la pérdida de tres modelos
mse para calcular la pérdida de tres modelos sobre los mismos datos: (w=0, b=0), (w=1.5, b=0.5) y (w=2, b=0). Antes de ejecutar, calcula a mano el primero y el tercero; después compara.Pista
Con w = 0 y b = 0 los errores son -2, -4, -6, -8. Sus cuadrados suman 120 y hay cuatro muestras.
Parar cuando el gradiente sea pequeño
|gw| y |gb| sean menores que 0.001. Imprime la época en la que ocurre y los parámetros en ese momento. ¿Cuántas épocas tarda en comparación con las 500 fijas?Pista
Comprueba la condición justo después de calcular los gradientes, antes de actualizar, o después de actualizar, pero sé coherente. Incluye <cmath> para std::fabs.
for (std::size_t i = 0; i < n; ++i) { const double error = w * x[i] + b - ; gw += 2.0 * * error / n; gb += 2.0 * error / n; } w -= * gw; b -= lr * gb;
El error es predicción menos valor real; el gradiente de `w` pesa cada error por su entrada; la tasa se llama `lr`.
Soluciones explicadas
En el primer ejercicio la comprobación a mano es el punto: si tu cálculo de 30 y de 0 coincide con el programa, la función de pérdida está bien escrita. Ese hábito detecta el noventa por ciento de los errores de implementación antes de mirar el entrenamiento completo.
En el segundo, la parada por gradiente dice algo sobre el problema: 347 épocas para una recta de dos parámetros es mucho. La razón es que x va de 1 a 4 y la matriz de segundas derivadas tiene valores muy distintos en sus dos direcciones, así que el descenso avanza deprisa en una y despacio en la otra. Centrar x restando su media reduce esa diferencia y acorta el entrenamiento.
Resumen
- Entrenar es ajustar parámetros para minimizar una pérdida medible.
- El MSE promedia los errores al cuadrado y vale 0 cuando el modelo acierta en todas las muestras.
- Los gradientes son
(2/n)·Σ x·ey(2/n)·Σ e; la actualización restalrveces el gradiente. - Un paso con
lr = 0.05desde(0, 0)da(1.5, 0.5)y baja la pérdida de 30 a 0.875. - Tras 500 épocas el modelo llega a
w = 1.999890,b = 0.000324y predice 9.999773 donde el valor exacto es 10. - Un criterio de parada por gradiente es más robusto que un número fijo de épocas.
- Centrar y escalar las entradas acelera la convergencia sin cambiar el mínimo.
Evaluación
Con w = 0 y b = 0, ¿cuánto vale el MSE sobre x = [1, 2, 3, 4], y = [2, 4, 6, 8]?
Si el gradiente respecto de w es negativo, ¿qué hace un paso de gradiente descendente?
La pérdida baja durante veinte épocas y de repente empieza a crecer hasta infinito. ¿Cuál es la causa más probable?
¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.