Curso de ML EN

Capítulo 25 de 37 · intermedio

Máquinas de vectores de soporte con SGD

Qué cubre este capítulo

La regresión logística trazaba una frontera recta y le pedía a cada punto una probabilidad. La máquina de vectores de soporte también traza una frontera recta, pero hace otra pregunta: no "qué tan probable es cada etiqueta", sino "cuál es el corredor vacío más ancho que puedo meter entre las dos clases". Solo le importan los puntos cercanos al borde — los que se moverían si la línea cambiara — e ignora todo lo que está tranquilo en el interior. Esa sola idea, maximizar el margen, resultó ser una de las más duraderas del machine learning.

Construimos la SVM lineal desde cero en NumPy: la función de decisión, el hinge loss que castiga a los puntos por invadir el margen, el término L2 que controla qué tan ancho queda ese margen, el subgradiente y un ciclo de descenso de gradiente estocástico que baja todo eso cuesta abajo, un minibatch a la vez. Después dejamos que SGDClassifier(loss="hinge") de scikit-learn ajuste el mismo modelo y verificamos que los números cuadren. La pieza central, igual que el capítulo pasado, es verlo entrenar — pero esta vez estás viendo un corredor. Cada cuadro dibuja la frontera y sus dos líneas de margen sobre un dataset en 2-D, marca con un anillo los vectores de soporte, señala los errores y traza el hinge loss cayendo abajo. El margen entra barriendo desde las orillas y se acomoda en el hueco entre las clases, y al final puedes ver exactamente cuál puñado de puntos es el que sostiene la línea en su lugar.

Un poco de historia

La idea del margen es vieja y viene de la teoría, no de un benchmark. Vladimir Vapnik y Alexey Chervonenkis la sacaron en la Unión Soviética en 1963, como parte de la teoría del aprendizaje estadístico que estaban construyendo — la teoría VC que después llevó las iniciales de ambos. Su resultado fue que la capacidad de un clasificador para generalizar está atada al margen que deja alrededor de la frontera: un hueco ancho y seguro entre las clases no solo se ve más bonito, es demostrablemente menos propenso al overfitting. Esa es una afirmación más fuerte que "funciona con mis datos", y por eso la SVM llegó con reputación.

El método que casi todos conocen tomó dos pasos más, ambos con Vapnik en la sala. En 1992 Bernhard Boser, Isabelle Guyon y Vapnik agregaron el kernel trick — una forma de obtener una frontera curva midiendo similitud en un espacio transformado sin nunca construir ese espacio de manera explícita. Luego, en 1995, Corinna Cortes y Vapnik publicaron la SVM de margen suave, que deja que unos cuantos puntos se metan dentro del margen o queden del lado equivocado a cambio de una frontera que no se arruina por un solo outlier. Ese margen suave, entrenado con hinge loss y una penalización L2, es exactamente el modelo que construimos aquí. Durante finales de los noventa y los dos mil, la SVM fue el clasificador fuerte por defecto, lo que tenías que superar. El único pero era el costo de entrenamiento: la SVM de libro es un programa cuadrático que escala mal con el número de ejemplos. El arreglo que la hizo escalar es el del título de este capítulo — en 2007 Shai Shalev-Shwartz y sus coautores publicaron Pegasos, que mostró que podías entrenar una SVM con puro descenso de gradiente estocástico sobre el hinge loss y llegar a la misma solución mucho más rápido. Aquí hacemos la forma lineal con SGD. El kernel trick es la extensión natural — lo menciono al final, pero no lo vamos a implementar.

La intuición

Aquí hay un dataset en 2-D, dos clases, algo de traslape. Dos features en los ejes, color para la etiqueta real. Estos son los puntos entre los cuales la SVM va a meter su corredor.

Hay un montón de rectas que separan casi todos estos puntos. La regresión logística habría escogido una pidiéndole a cada punto que votara con una probabilidad. La SVM escoge distinto. Imagina que ensanchas una banda alrededor de tu línea candidata — separando dos paredes paralelas, una de cada lado, hasta que chocan con los puntos más cercanos. La línea que la SVM quiere es aquella cuya banda es la más ancha. Nada de lo que está en medio de una nube tiene voz; podría moverse un buen tramo sin tocar una pared. Solo importan los puntos justo en las orillas — aquellos contra los que se recargan las paredes. Esos son los vectores de soporte, y son todo el modelo. Borra todos los demás puntos y obtendrías la frontera idéntica.

Ese es el cambio de mentalidad que vale la pena cargar. La regresión logística es un modelo de todos los datos. Una SVM es un modelo de la frontera. Cuando las clases se separan limpiamente, el segundo enfoque te da una línea más estable, porque está anclada a los pocos puntos que de verdad definen la frontera en lugar de andar siendo jaloneada por cada punto de la nube.

Las matemáticas

Arrancamos con el mismo score lineal del capítulo pasado. Para un vector de features xix_i con DD entradas, pesos ww y sesgo bb:

f(xi)=wxi+b=j=1Dwjxij+bf(x_i) = w^{\top} x_i + b = \sum_{j=1}^{D} w_j\, x_{ij} + b

La frontera es la superficie plana f(x)=0f(x) = 0. Predecimos una etiqueta por su signo, y aquí las etiquetas viven en {1,+1}\{-1, +1\} en lugar de {0,1}\{0, 1\} — esa elección es la que hace que todo lo de abajo salga limpio:

y^i=sign(f(xi))\hat{y}_i = \operatorname{sign}\big(f(x_i)\big)

Ahora el margen. El margen funcional de un punto es yif(xi)y_i\, f(x_i): positivo cuando el punto está del lado correcto, negativo cuando está mal clasificado, y más grande entre más segura sea la decisión. El margen geométrico — la distancia real del punto a la frontera — es esa cantidad dividida entre w\lVert w \rVert. Fijamos la escala declarando que las líneas de margen son las dos superficies f(x)=+1f(x) = +1 y f(x)=1f(x) = -1. La distancia perpendicular entre ellas resulta ser

margin width=2w\text{margin width} = \frac{2}{\lVert w \rVert}

así que maximizar el margen significa minimizar w\lVert w \rVert. Ese es todo el truco escondido a plena vista: un vector de pesos más chico es un corredor más ancho.

Si las clases fueran perfectamente separables nada más minimizaríamos w\lVert w \rVert sujeto a que cada punto quede en su margen o más allá, yif(xi)1y_i f(x_i) \ge 1. Los datos reales se traslapan, así que relajamos esa restricción dura y la convertimos en una penalización — el hinge loss:

i=max(0,  1yif(xi))\ell_i = \max\big(0,\; 1 - y_i\, f(x_i)\big)

Léelo desde el margen. Un punto con yif(xi)1y_i f(x_i) \ge 1 está sobre su línea de margen o más allá y no paga nada. Un punto dentro del margen (0<yif<10 < y_i f < 1) o mal clasificado (yif<0y_i f < 0) paga de forma lineal según qué tanto le faltó. No hay premio por estar extra lejos pasando el margen — una vez que libraste, libraste. Juntando las dos mitades obtienes el objetivo de margen suave que minimiza SGD:

J(w,b)=λ2w2wide margin+1Ni=1Nmax(0,1yif(xi))few violations\mathcal{J}(w, b) = \underbrace{\frac{\lambda}{2}\lVert w \rVert^{2}}_{\text{wide margin}} + \underbrace{\frac{1}{N}\sum_{i=1}^{N} \max\big(0,\, 1 - y_i\, f(x_i)\big)}_{\text{few violations}}

La fuerza de regularización λ\lambda es la única perilla que importa: súbela y el modelo premia un margen ancho, tolerando más puntos adentro; bájala y el margen se angosta para evitar violaciones. Es la misma perilla de sesgo-varianza con otro nombre.

El hinge tiene una esquina en yif=1y_i f = 1, así que no es diferenciable en todas partes y usamos un subgradiente. Solo los puntos que siguen pagando — los que tienen yif(xi)<1y_i f(x_i) < 1 — empujan la frontera:

Jw=λw    1N ⁣ ⁣i:yif(xi)<1 ⁣ ⁣yixiJb=1N ⁣ ⁣i:yif(xi)<1 ⁣ ⁣yi\frac{\partial \mathcal{J}}{\partial w} = \lambda w \;-\; \frac{1}{N}\!\!\sum_{i:\, y_i f(x_i) < 1}\!\! y_i\, x_i \qquad \frac{\partial \mathcal{J}}{\partial b} = -\frac{1}{N}\!\!\sum_{i:\, y_i f(x_i) < 1}\!\! y_i

El término de regularización λw\lambda w siempre jala los pesos hacia cero, ensanchando el margen; el término de datos empuja la frontera lejos de cualquier punto que la esté invadiendo. SGD estima esa suma con un minibatch aleatorio chico en vez del conjunto completo, que es lo que le permite escalar. Fíjate que el sesgo bb no lleva término λ\lambda — regularizamos la orientación del plano, no dónde se sienta.

En qué es bueno y en qué no

La fuerza de la SVM es el margen, y es una fuerza real. Una frontera escogida para quedar lo más lejos posible de ambas clases tiende a generalizar bien — eso no es folclor, es lo que predice la teoría VC y lo que sientes en la práctica como robustez. Como el modelo depende únicamente de los vectores de soporte, es estable: los puntos bien metidos dentro de una clase pueden moverse o desaparecer y la frontera ni se inmuta. Y con el kernel trick encima, la misma maquinaria traza fronteras altamente no lineales, que es lo que hizo de las SVM el clasificador fuerte de propósito general durante una década. El hinge loss también es buen ciudadano — convexo, así que el entrenamiento tiene una sola cuenca, y deja de preocuparse por un punto en cuanto queda bien clasificado, a diferencia de una pérdida que sigue persiguiendo ejemplos que ya estaban correctos.

Los costos son la otra cara. La SVM simple te entrega una etiqueta, no una probabilidad — no sale de ahí un P(y=1)P(y=1) calibrado como sale de la regresión logística, y el arreglo de siempre (Platt scaling) es un parche del que tienes que confiar. La versión con kernel escala mal: el programa cuadrático es de orden más o menos cuadrático a cúbico en el número de ejemplos, que es precisamente por lo que existe el entrenamiento con SGD y por lo que el kernel trick fue quedando en el olvido en cuanto los datasets crecieron. Y tiene un hiperparámetro real que tunear en λ\lambda (o CC, su inverso), más la elección del kernel si te vas a lo no lineal — más perillas que una regresión logística que ajustas y olvidas. El escalado de features importa aquí más que en casi cualquier otro lado, y de eso va lo que sigue.

Los datos

Para este capítulo el dataset es deliberadamente pequeño y bidimensional para que la geometría se lea bien: 120 puntos, dos clases de 60, generados como un par de nubes gaussianas con suficiente dispersión para que se traslapen en el centro. Una recta separa a la mayoría y un puñado queda del lado equivocado — que es justo lo que queremos, para que haya vectores de soporte honestos y un par de errores genuinos que observar. Los datos crudos están en data/blobs.csv; el README anota exactamente cómo se generaron y con qué semilla.

Hay un paso de preprocesamiento que no es opcional para una SVM: estandariza las features primero. El modelo mide distancia a un hiperplano, y la distancia queda dominada por la feature que tenga el rango numérico más grande. Deja una feature en cientos y otra en fracciones, y el margen se va a contorsionar en silencio para complacer a la grande — la geometría deja de significar lo que crees que significa. Así que restamos la media de cada columna y dividimos entre su desviación estándar, calculadas sobre los datos de entrenamiento y reaplicadas a cualquier dato apartado, para que toda feature contribuya a la distancia en igualdad de condiciones. Nuestras nubes ya viven en escalas parecidas, así que estandarizar casi no las mueve aquí, pero es el hábito que conviene formar desde ahora, porque en datos tabulares reales saltártelo va a arruinar una SVM en silencio, cuando a un árbol nada más lo incomoda.

Constrúyelo, una función a la vez

Siete piezas pequeñas, de abajo hacia arriba. Empezamos con el score, la misma combinación lineal que usaba la regresión logística antes de aplastar nada.

def decision_function(X, w, b):
    """The raw signed score f(x) = w·x + b, one per row.

    X is (N, D), w is (D,), b is a scalar. This is the distance-to-boundary
    signal before we take its sign: positive on one side of the hyperplane,
    negative on the other, zero exactly on it. Its magnitude is how far the
    point sits from the boundary, in units of 1 / ||w||.
    """
    return X @ w + b

Para convertir scores en etiquetas, toma el signo. Aquí es donde la convención {1,+1}\{-1, +1\} se gana su lugar — la etiqueta es simplemente de qué lado del cero cae el score, sin ningún umbral que nombrar.

def predict(X, w, b):
    """Label each row by which side of the boundary it falls on.

    Return +1 when the score is non-negative, -1 otherwise — the sign of the
    decision function. Ties (a point exactly on the boundary) go to +1.
    """
    return np.where(decision_function(X, w, b) >= 0, 1, -1)

Ahora la pérdida. El hinge es el corazón del modelo: lee el margen de cada punto y solo le cobra si está invadiendo el corredor.

def hinge_loss(X, y, w, b):
    """Mean hinge loss over the dataset, labels in {-1, +1}.

    Per point: max(0, 1 - y·f(x)). The quantity y·f(x) is the *margin* of the
    point — positive when it's classified correctly, and >= 1 when it sits at
    or beyond the margin line, where it costs nothing. A point inside the
    margin (0 < y·f < 1) or misclassified (y·f < 0) pays linearly. That
    hinge is what pushes the boundary until the classes are cleanly separated
    with room to spare.
    """
    margins = y * decision_function(X, w, b)
    return float(np.mean(np.maximum(0.0, 1.0 - margins)))

El hinge por sí solo dejaría felizmente que w\lVert w \rVert se fuera al infinito, ya que un vector de pesos más grande hace que cada punto correcto se vea más seguro. El término L2 lo frena, y los dos juntos son el objetivo completo — lo que el entrenamiento minimiza de verdad:

def objective(X, y, w, b, lam):
    """The full soft-margin objective SGD minimizes.

    Regularized hinge loss: a data term that wants every point past its margin,
    plus an L2 term (lam/2)·||w||² that wants w small. Shrinking ||w|| widens
    the margin (its width is 2 / ||w||), so lam is the dial that trades margin
    width against training mistakes. Small lam → narrow margin, few violations;
    large lam → wide margin, more points allowed inside it. The bias b is not
    regularized — only the orientation of the plane pays the penalty.
    """
    reg = 0.5 * lam * float(w @ w)
    return reg + hinge_loss(X, y, w, b)

Ese lam es toda la personalidad del modelo. Un lam grande mantiene w chico y el margen ancho; un lam chico deja crecer a w para eliminar violaciones. Sigue el gradiente — un subgradiente, porque el hinge tiene esa esquina. Solo contribuyen los puntos que siguen pagando a la pérdida:

def subgradient(Xb, yb, w, b, lam):
    """(Sub)gradient of the objective over a minibatch.

    The hinge has a kink at y·f = 1, so we use a subgradient: only the points
    with margin < 1 (inside the margin or misclassified) push the boundary;
    the rest contribute nothing to the data term. For those active points the
    gradient of the hinge is -y·x for w and -y for b. The L2 term always adds
    lam·w. Averaging the data term over the minibatch is what makes this
    *stochastic* — a noisy but cheap estimate of the full gradient.
    """
    margins = yb * (Xb @ w + b)
    active = margins < 1.0                       # points still paying hinge
    n = len(yb)
    dw = lam * w - (Xb[active].T @ yb[active]) / n
    db = -float(np.sum(yb[active])) / n          # bias is not regularized
    return dw, db

Fíjate en lo que está y en lo que no. La máscara active selecciona exactamente los puntos que están dentro del margen o del lado equivocado — los vectores de soporte en formación — y solo ellos empujan la frontera. Todo lo que está cómodamente clasificado sale por completo de la suma. Ese enmascarado es la jugada definitoria de la SVM escrita en una línea de NumPy.

Ahora el ciclo de entrenamiento. Arranca el plano en cero, revuelve en cada epoch con un generador con semilla para que la corrida sea reproducible, y da un paso de subgradiente por minibatch:

def fit(X, y, lam=0.01, lr=0.1, n_epochs=45, batch_size=12, seed=0, record=False):
    """Train the SVM by stochastic gradient descent.

    Start the plane flat (w = 0, b = 0). Each epoch, shuffle the rows with a
    seeded generator (so runs are reproducible), walk them in minibatches, and
    take one subgradient step per batch. With `record=True` we snapshot
    (w, b, hinge, objective) at the end of every epoch so the chapter can
    replay the margin widening frame by frame.
    """
    rng = np.random.default_rng(seed)
    N, D = X.shape
    w = np.zeros(D)
    b = 0.0
    history = []

    def snap():
        history.append((w.copy(), b,
                        hinge_loss(X, y, w, b),
                        objective(X, y, w, b, lam)))

    if record:
        snap()
    for _ in range(n_epochs):
        idx = rng.permutation(N)
        for start in range(0, N, batch_size):
            bi = idx[start:start + batch_size]
            dw, db = subgradient(X[bi], y[bi], w, b, lam)
            w -= lr * dw
            b -= lr * db
        if record:
            snap()
    if record:
        return w, b, history
    return w, b

Esto es lo que pone el "SGD" en el título del capítulo. El descenso de gradiente por lotes sumaría el gradiente sobre los 120 puntos antes de cada paso; el descenso estocástico lo estima con doce a la vez y da pasos mucho más seguido. En este conjunto de juguete la diferencia es académica, pero es la razón completa de que las SVM escalen a millones de ejemplos — nunca tienes que tocar todo el dataset para dar un paso. La bandera record guarda el estado al final de cada epoch para que podamos repetir el entrenamiento.

Última pieza, la que hace que esto sea una SVM y no nada más un clasificador lineal: cuáles puntos son los vectores de soporte. Cualquiera con margen 1\le 1 — sobre la línea, dentro de ella o mal clasificado.

def support_vectors(X, y, w, b, tol=1e-6):
    """Boolean mask of the support vectors: points with margin y·f(x) <= 1.

    These are the only points that shape the boundary — the ones on the margin
    line, inside it, or misclassified. Move any of them and the plane moves;
    delete a point comfortably beyond its margin and nothing changes. That's
    the whole idea of a support vector machine: the decision surface is
    supported by a handful of points, not the whole dataset.
    """
    margins = y * decision_function(X, w, b)
    return margins <= 1.0 + tol

Míralo funcionar

Este es el entrenamiento mismo, sobre el conjunto en 2-D para que cada punto sea visible. El panel de arriba son los datos; la línea naranja sólida es la frontera de decisión actual, y las dos líneas punteadas son los márgenes, f(x)=±1f(x) = \pm 1. Un punto recibe un anillo ámbar en el momento en que se vuelve vector de soporte — sobre o dentro de una línea de margen — y una cruz roja si la frontera de plano lo clasifica mal. El panel de abajo traza el hinge loss, un punto por epoch.

Dale play. Cada cuadro es un epoch real de fit, los mismos pesos que produjo el código. Reinicia y córrelo otra vez las veces que quieras.

Fíjate en el primer movimiento. El plano arranca plano — w=0w = 0, ninguna línea — así que todavía no hay corredor y, durante un cuadro, los 120 puntos cuentan como vectores de soporte porque nada tiene margen. Luego entra el gradiente, aparece una frontera y las dos líneas de margen vienen con ella. Aquí está lo que hay que notar, y es lo opuesto a la historia típica de "el margen se ensancha": como arrancamos desde un plano plano, el margen empieza enorme — w\lVert w \rVert es diminuto, y el ancho del margen es 2/w2 / \lVert w \rVert — y barre hacia adentro, con las dos líneas punteadas cerrándose desde las orillas de la gráfica hacia el hueco entre las clases. El mismo equilibrio que dibuja el libro de texto, alcanzado desde el lado ancho en vez del angosto. Conforme se acomoda puedes ver cómo se desploma el conteo de vectores de soporte: de los 120, bajando por las decenas, hasta que solo los puntos genuinamente cercanos a la frontera conservan su anillo. Para el último epoch el hinge loss se aplanó en 0.156, el margen se acomodó en un ancho de 1.27, quedan 33 puntos como vectores de soporte y 6 puntos tercos dentro del traslape siguen mal clasificados — accuracy de entrenamiento 0.95. La curva de pérdida cuenta la misma historia de perfil: un acantilado en los primeros epochs mientras se arreglan las violaciones grandes, y luego un planeo largo y plano mientras la frontera se afina contra los vectores de soporte.

Vale la pena detenerse en las seis cruces rojas del final. La SVM no falló en colocarlas — eligió comerse esos seis errores porque la alternativa, contorsionar la frontera para atraparlos, habría costado más margen de lo que valía. Ese intercambio, unas cuantas violaciones a cambio de un corredor ancho y limpio, es el margen suave haciendo exactamente su trabajo, y λ\lambda es la perilla que fija el tipo de cambio.

La implementación completa

El archivo entero, de principio a fin — la función de decisión, el hinge, el objetivo, el subgradiente, el ciclo de SGD y los helpers de vectores de soporte, accuracy y estandarización que usan los traces. Este es el código que la animación de arriba realmente corrió:

"""Linear support vector machine, built from scratch.

The soft-margin SVM: find the hyperplane that separates the two classes with
the widest possible margin, paying a hinge penalty for every point that lands
inside the margin or on the wrong side. We train it by stochastic gradient
descent on the regularized hinge loss — no quadratic program, no library, just
the (sub)gradient and a step size.

Labels live in {-1, +1} (not {0, 1}) — that convention is what makes the hinge
loss and the margin come out clean. Pure NumPy (+ pandas for loading). Every
function below appears in the chapter one step at a time; the `# region:`
markers are what the book's include directives pull in.
"""

import numpy as np
import pandas as pd


# region: decision_function
def decision_function(X, w, b):
    """The raw signed score f(x) = w·x + b, one per row.

    X is (N, D), w is (D,), b is a scalar. This is the distance-to-boundary
    signal before we take its sign: positive on one side of the hyperplane,
    negative on the other, zero exactly on it. Its magnitude is how far the
    point sits from the boundary, in units of 1 / ||w||.
    """
    return X @ w + b
# endregion


# region: predict
def predict(X, w, b):
    """Label each row by which side of the boundary it falls on.

    Return +1 when the score is non-negative, -1 otherwise — the sign of the
    decision function. Ties (a point exactly on the boundary) go to +1.
    """
    return np.where(decision_function(X, w, b) >= 0, 1, -1)
# endregion


# region: hinge_loss
def hinge_loss(X, y, w, b):
    """Mean hinge loss over the dataset, labels in {-1, +1}.

    Per point: max(0, 1 - y·f(x)). The quantity y·f(x) is the *margin* of the
    point — positive when it's classified correctly, and >= 1 when it sits at
    or beyond the margin line, where it costs nothing. A point inside the
    margin (0 < y·f < 1) or misclassified (y·f < 0) pays linearly. That
    hinge is what pushes the boundary until the classes are cleanly separated
    with room to spare.
    """
    margins = y * decision_function(X, w, b)
    return float(np.mean(np.maximum(0.0, 1.0 - margins)))
# endregion


# region: objective
def objective(X, y, w, b, lam):
    """The full soft-margin objective SGD minimizes.

    Regularized hinge loss: a data term that wants every point past its margin,
    plus an L2 term (lam/2)·||w||² that wants w small. Shrinking ||w|| widens
    the margin (its width is 2 / ||w||), so lam is the dial that trades margin
    width against training mistakes. Small lam → narrow margin, few violations;
    large lam → wide margin, more points allowed inside it. The bias b is not
    regularized — only the orientation of the plane pays the penalty.
    """
    reg = 0.5 * lam * float(w @ w)
    return reg + hinge_loss(X, y, w, b)
# endregion


# region: subgradient
def subgradient(Xb, yb, w, b, lam):
    """(Sub)gradient of the objective over a minibatch.

    The hinge has a kink at y·f = 1, so we use a subgradient: only the points
    with margin < 1 (inside the margin or misclassified) push the boundary;
    the rest contribute nothing to the data term. For those active points the
    gradient of the hinge is -y·x for w and -y for b. The L2 term always adds
    lam·w. Averaging the data term over the minibatch is what makes this
    *stochastic* — a noisy but cheap estimate of the full gradient.
    """
    margins = yb * (Xb @ w + b)
    active = margins < 1.0                       # points still paying hinge
    n = len(yb)
    dw = lam * w - (Xb[active].T @ yb[active]) / n
    db = -float(np.sum(yb[active])) / n          # bias is not regularized
    return dw, db
# endregion


# region: fit
def fit(X, y, lam=0.01, lr=0.1, n_epochs=45, batch_size=12, seed=0, record=False):
    """Train the SVM by stochastic gradient descent.

    Start the plane flat (w = 0, b = 0). Each epoch, shuffle the rows with a
    seeded generator (so runs are reproducible), walk them in minibatches, and
    take one subgradient step per batch. With `record=True` we snapshot
    (w, b, hinge, objective) at the end of every epoch so the chapter can
    replay the margin widening frame by frame.
    """
    rng = np.random.default_rng(seed)
    N, D = X.shape
    w = np.zeros(D)
    b = 0.0
    history = []

    def snap():
        history.append((w.copy(), b,
                        hinge_loss(X, y, w, b),
                        objective(X, y, w, b, lam)))

    if record:
        snap()
    for _ in range(n_epochs):
        idx = rng.permutation(N)
        for start in range(0, N, batch_size):
            bi = idx[start:start + batch_size]
            dw, db = subgradient(X[bi], y[bi], w, b, lam)
            w -= lr * dw
            b -= lr * db
        if record:
            snap()
    if record:
        return w, b, history
    return w, b
# endregion


# region: support_vectors
def support_vectors(X, y, w, b, tol=1e-6):
    """Boolean mask of the support vectors: points with margin y·f(x) <= 1.

    These are the only points that shape the boundary — the ones on the margin
    line, inside it, or misclassified. Move any of them and the plane moves;
    delete a point comfortably beyond its margin and nothing changes. That's
    the whole idea of a support vector machine: the decision surface is
    supported by a handful of points, not the whole dataset.
    """
    margins = y * decision_function(X, w, b)
    return margins <= 1.0 + tol
# endregion


def accuracy(X, y, w, b):
    """Fraction of rows the model labels correctly."""
    return float((predict(X, w, b) == y).mean())


def standardize(X, mean=None, std=None):
    """Center and scale each column to mean 0, unit variance.

    SVMs measure distance to a hyperplane, so a feature on a larger numeric
    scale silently dominates that distance and the margin tilts to please it.
    Standardizing puts every feature on equal footing before training; the same
    mean and std must then be applied to any held-out data. Returns the
    transformed X plus the mean and std so the test set gets the identical shift.
    """
    if mean is None:
        mean = X.mean(axis=0)
    if std is None:
        std = X.std(axis=0)
        std = np.where(std == 0, 1.0, std)
    return (X - mean) / std, mean, std


def load_data(path="../data/blobs.csv"):
    """Load the committed 2-D two-class dataset. Labels are in {-1, +1}."""
    df = pd.read_csv(path)
    X = df[["x1", "x2"]].to_numpy(float)
    y = df["label"].to_numpy(int)
    return X, y

La versión con librería

No harías esto a mano para producción, y no hace falta. El SGDClassifier(loss="hinge") de scikit-learn es el mismo modelo — una SVM lineal de margen suave entrenada con descenso de gradiente estocástico sobre exactamente el objetivo de arriba — y te devuelve los pesos ajustados y el sesgo igual:

def sklearn_svm(Xtr, ytr, Xte, yte, alpha=0.01):
    """Fit sklearn's SGDClassifier(loss="hinge") on standardized features.

    `alpha` is the L2 strength — the same role as our `lam`. We hand it the
    already-standardized arrays so it's judged on exactly the footing as our
    from-scratch model. Returns the learned weights, bias, and test accuracy.
    """
    clf = SGDClassifier(loss="hinge", penalty="l2", alpha=alpha,
                        max_iter=2000, tol=1e-4, random_state=0)
    clf.fit(Xtr, ytr)
    w = clf.coef_[0]
    b = float(clf.intercept_[0])
    acc = float(clf.score(Xte, yte))
    return w, b, acc

Un par de cosas difieren por dentro. El alpha de sklearn es nuestro lam, la fuerza L2, y los igualamos para que la comparación sea honesta. Su learning rate no es fijo como el nuestro — por default usa un esquema adaptativo que encoge el paso con el tiempo, más cercano a la receta de Pegasos y que suele converger en menos pasadas. También hace su propio shuffle y tiene una tolerancia de early stopping. Le pasamos los arreglos ya estandarizados para que se le juzgue en igualdad de condiciones — mismas features, mismo escalado, mismo split.

Hay una segunda librería que vale la pena nombrar, porque es la SVM que la mayoría aprendió primero. LinearSVC resuelve el mismo problema de margen suave, pero como el programa cuadrático que originalmente plantearon Cortes y Vapnik — un solver exacto en lugar de una aproximación por SGD. Es la herramienta correcta cuando el dataset es lo bastante chico como para preferir el margen exacto en vez de cambiar accuracy por velocidad, y sirve como buen punto de referencia: si nuestro SGD está haciendo bien su trabajo, debería caer justo al lado de la respuesta de LinearSVC.

def sklearn_linsvc(Xtr, ytr, Xte, yte, C=1.0):
    """Fit LinearSVC — the same SVM solved as a quadratic program, not by SGD.

    A reference point: if our SGD is doing its job it should land near this
    exact-solver accuracy. `C` is the inverse of regularization strength.
    """
    clf = LinearSVC(C=C, max_iter=5000, random_state=0)
    clf.fit(Xtr, ytr)
    return float(clf.score(Xte, yte))

Desde cero contra la librería

Partimos los datos 70/30 — 84 puntos para entrenar, 36 apartados —, estandarizamos con las estadísticas de entrenamiento, ajustamos los tres modelos y evaluamos sobre el conjunto apartado. Aquí está el careo:

Los tres caen en 0.9444 — nuestro SGD desde cero, el SGDClassifier de sklearn y el solver exacto LinearSVC coinciden a cuatro decimales sobre el conjunto apartado. Ese es el resultado que quieres. No es que le atinen por casualidad a los mismos 34 de 36 puntos; es que este es un problema convexo con esencialmente una sola frontera buena, y tres caminos distintos llegan a ella. Los pesos lo respaldan: nuestra SVM cae en w=[1.31,0.97]w = [1.31, 0.97] con sesgo 0.060.06, y SGDClassifier en w=[1.30,0.97]w = [1.30, 0.97] con sesgo 0.040.04 — el mismo plano a dos decimales, desde dos optimizadores distintos. Cuando tu subgradiente escrito a mano cae en la respuesta de la librería, entendiste el modelo en lugar de nada más importarlo.

Sobre el split de entrenamiento nuestra SVM se apoya en 25 vectores de soporte de 84 — digamos que un tercio de los datos hace todo el trabajo mientras los otros dos tercios están lo bastante lejos de la frontera como para ser irrelevantes. Esa es la compresión que te compra la SVM: una frontera definida por una minoría de los puntos. Las advertencias honestas son las de siempre. Este es un dataset pequeño, de baja dimensión y más o menos balanceado, así que aquí la accuracy es un resumen justo de una forma en que no lo sería en un problema desbalanceado; y el split apartado es lo que mantiene el número con significado — la accuracy de entrenamiento saldría más alta y te diría menos.

Puntos clave

La máquina de vectores de soporte vale la pena tenerla en el kit incluso en un mundo que casi siempre echa mano de gradient boosting. Su instinto — encontrar el hueco más ancho y dejar que solo los puntos de la frontera la definan — produce clasificadores que generalizan limpio y no se tambalean cuando perturbas los ejemplos fáciles. En un problema linealmente separable o casi separable es un baseline genuinamente fuerte y estable, y el margen que reporta te dice algo que la regresión logística no: cuánto espacio tiene realmente la frontera.

Tres cosas para llevarte. Primera, el margen como objetivo: maximizar 2/w2/\lVert w \rVert es lo mismo que minimizar w\lVert w \rVert, y por eso el término L2 aquí no es un agregado de último momento como puede serlo en otros lados — es el margen. Segunda, SGD sobre el hinge loss es lo que hace que la SVM escale; el elegante programa cuadrático es lo que aprendes, pero el descenso de gradiente estocástico sobre minibatches es lo que corres cuando los datos son grandes, y llega al mismo lugar. Tercera, la elección entre esto y la regresión logística se reduce a qué necesitas del modelo: usa regresión logística cuando quieras una probabilidad calibrada y pesos legibles por feature, usa la SVM cuando quieras la frontera de separación más estable y puedas vivir con una etiqueta pelona. Y cuando la frontera es genuinamente curva, ningún modelo lineal puede seguirla — ahí entra el kernel trick, cambiando cada producto punto por una función de similitud para que la misma maquinaria maximizadora de margen trace fronteras no lineales sin salirse nunca de estas matemáticas. No lo implementamos, pero todo en este capítulo es su caso especial lineal, y ese es el paso natural que sigue cada vez que una recta no alcanza.