Curso de ML EN

Capítulo 36 de 37 · avanzado

Redes neuronales convolucionales

Qué cubre este capítulo

El perceptrón multicapa del capítulo pasado acepta una imagen sin problema, pero la toma como una bolsa de pixeles. Aplana un dígito de 8×8 en 64 números y cada pixel se queda con su propio peso, sin relación con sus vecinos, y la red no tiene idea de que el pixel 9 está justo debajo del pixel 1. Una red convolucional arregla exactamente eso. Trae de fábrica lo que ya sabemos de las imágenes —que un patrón útil es local, y que un patrón que vale la pena detectar en una esquina vale la pena detectarlo en todas partes— y lo hace con una sola operación repetida a lo largo de la imagen.

Este capítulo trata de esa única operación. Construimos la convolución desde cero en NumPy: un filtro deslizándose sobre una imagen, un ReLU que se queda con las respuestas que valen la pena, y max-pooling que encoge el resultado sin dejar de tolerar pequeños desplazamientos. Luego la vemos trabajar, pixel por pixel, y la sometemos a una prueba chica pero honesta: tomamos los dígitos manuscritos que trae scikit-learn, corremos sobre ellos un banco fijo de filtros de bordes, y le damos los feature maps resultantes a un clasificador lineal simple. El mismo clasificador sobre pixeles crudos es el control. La convolución gana, por poco pero de verdad, y el punto es ver por qué.

Una aclaración por delante, porque la honestidad importa. Estamos enseñando el mecanismo de la convolución, no entrenando una red profunda. Los filtros aquí son detectores de bordes fijos, no aprendidos, y hay exactamente una etapa convolucional. Una CNN de producción aprende sus filtros de punta a punta con backprop y apila decenas de capas, y lo hace en torch o tensorflow, no en un loop de NumPy. Lo que vas a construir es el núcleo honesto que esos frameworks optimizan.

Un poco de historia

La idea es más vieja que el hardware que la hizo famosa. En 1980 Kunihiko Fukushima publicó el Neocognitron, una red por capas con campos receptivos locales y pesos compartidos, inspirada explícitamente en el trabajo de Hubel y Wiesel sobre la corteza visual del gato y sus células simples y complejas. Tenía toda la arquitectura —detectores locales de características, y luego un paso de pooling que toleraba cambios de posición— pero ninguna forma de entrenarla por descenso de gradiente. Aprendía, pero a tropezones.

Yann LeCun puso la mitad que faltaba. En 1989, en Bell Labs, entrenó una red convolucional con backpropagation para leer códigos postales manuscritos, y para 1998 esa línea de trabajo se convirtió en LeNet-5, la red del paper "Gradient-based learning applied to document recognition" que los bancos realmente desplegaron para leer los números de los cheques. La convolución, el pooling, los filtros entrenables, todo el asunto funcionó y salió a producción. Después se hizo el silencio por más de una década, porque era caro y los datasets eran chicos.

2012 es cuando el campo se volteó. Alex Krizhevsky, Ilya Sutskever y Geoffrey Hinton metieron una red convolucional profunda —AlexNet— a la competencia de ImageNet y bajaron la tasa de error por un margen que ni siquiera estuvo cerca. La misma idea de fondo que LeCun había puesto a trabajar en los noventa, ahora con GPUs, un millón de imágenes etiquetadas, activaciones ReLU y dropout. Todo lo que vino después es descendiente de ahí. La operación que estamos por construir a mano es la que arrancó los tres capítulos de esa historia.

La intuición

Piensa en qué hace que un dígito sea un dígito. No son los pixeles individuales; son los trazos: un borde aquí, una curva allá, una esquina donde se juntan dos trazos. Esos son patrones locales, de unos cuantos pixeles de ancho, y dónde aparecen importa menos que el hecho de que aparezcan. Un 7 es un trazo horizontal encima de uno diagonal, lo escribas arriba o abajo del recuadro.

Una convolución es un detector chiquito de patrones que deslizas sobre toda la imagen. Toma una rejilla diminuta de pesos —un filtro, digamos de 3×3—, alinéala sobre un parche de la imagen, multiplica las celdas que se traslapan y suma el resultado en un solo número. Ese número dice qué tan fuerte este parche coincide con el patrón que el filtro anda buscando. Desliza el filtro un pixel y hazlo otra vez. Bárrelo por toda la imagen y obtienes una imagen nueva, un feature map, donde cada pixel reporta la presencia de ese patrón en ese punto. Un filtro, un patrón, detectado en todas partes, con el mismo puñado de pesos reutilizado en cada posición.

Esa reutilización es todo el truco, y son dos ganancias de un jalón. Primero, un filtro que es bueno encontrando un borde vertical arriba a la izquierda es automáticamente bueno encontrando uno abajo a la derecha, porque son los mismos pesos idénticos: no tienes que reaprender el patrón para cada ubicación. Segundo, el número de parámetros se desploma. Una capa densa que conecta 64 pixeles de entrada con 64 salidas necesita 4,096 pesos; un filtro de 3×3 necesita 9, sin importar qué tan grande sea la imagen. Aquí están cinco de los dígitos con los que vamos a trabajar, dibujados como heatmaps: más brillante es más tinta.

Ocho por ocho es una resolución burda —están reducidos casi hasta la abstracción— pero los trazos están ahí, y un filtro afinado para bordes se va a encender justo a lo largo de ellos.

Las matemáticas

Una convolución —o correlación cruzada, la versión que de verdad usa el deep learning, que se salta el volteo del kernel— es una doble suma. Sea II la imagen de entrada y KK un kernel de altura khk_h y ancho kwk_w. La salida en la posición (i,j)(i, j) es el kernel colocado sobre el parche anclado ahí, multiplicado celda por celda y sumado:

S(i,j)=m=0kh1n=0kw1I(i+m, j+n)K(m,n)S(i, j) = \sum_{m=0}^{k_h - 1} \sum_{n=0}^{k_w - 1} I(i + m,\ j + n)\, K(m, n)

Eso es todo: el campo receptivo es el parche I(i:i+kh, j:j+kw)I(i \mathbin{:} i + k_h,\ j \mathbin{:} j + k_w), y S(i,j)S(i, j) es una suma ponderada de él. Deslízate por cada posición donde el kernel cabe completo dentro de la imagen (el modo "valid") y una imagen de H×WH \times W con un kernel de kh×kwk_h \times k_w produce una salida de (Hkh+1)×(Wkw+1)(H - k_h + 1) \times (W - k_w + 1). Nuestro dígito de 8×8 y filtro de 3×3 dan un feature map de 6×6.

La parte callada e importante de esa ecuación es lo que no está en ella: ii y jj indexan la posición, pero KK no. Los mismos pesos K(m,n)K(m, n) actúan en cada ubicación. Eso es weight sharing, y es el prior estructural: el mismo detector de características en todas partes, con un número de parámetros determinado por el tamaño del kernel y no por el tamaño de la imagen.

Después de la suma lineal viene una no linealidad, y para redes convolucionales casi siempre es la unidad lineal rectificada, que conserva las respuestas positivas y pone el resto en cero:

ReLU(x)=max(0, x)\mathrm{ReLU}(x) = \max(0,\ x)

Un filtro de bordes dispara positivo en una polaridad del borde y negativo en la otra, así que ReLU convierte "este borde, en este sentido, está aquí" en una señal limpia y manda la imagen espejo al silencio. Luego el pooling encoge el mapa. El max-pooling sobre bloques no traslapados de tamaño ss se queda con la respuesta más fuerte de cada bloque:

P(i,j)=max0a,b<sS(is+a, js+b)P(i, j) = \max_{0 \le a, b < s} S(i \cdot s + a,\ j \cdot s + b)

El pooling hace dos trabajos. Reduce el número de features y compra un poco de tolerancia a la traslación: mueve un borde un pixel dentro de un bloque de pooling y el máximo no cambia. Detección local, y luego un resumen al que no le importa el punto exacto: ese es el prior de las imágenes, hecho de tres ecuaciones.

En qué es buena y en qué no

La convolución es la herramienta correcta en el momento en que tus datos tienen estructura de rejilla y correlación local —imágenes sobre todo, pero también espectrogramas de audio, series de tiempo, cualquier señal donde las muestras cercanas se relacionan y un patrón puede aparecer en cualquier lado. Es frugal en parámetros, porque el weight sharing hace que la cuenta de pesos no crezca con el tamaño de la entrada, así que te puedes dar el lujo de muchos filtros y pilas profundas. Es tolerante a la traslación por construcción, gracias al pooling y al deslizamiento mismo. Y se compone: apila convoluciones y las capas tempranas encuentran bordes, las de en medio encuentran esquinas y texturas a partir de esos bordes, las tardías encuentran partes de objetos. Obtienes una jerarquía de features de a gratis, aprendida de los datos, y por eso la misma arquitectura lee dígitos, encuentra tumores y describe fotografías.

Los costos son la imagen espejo de los supuestos. La convolución apuesta a que la estructura útil es local y equivariante a la traslación, y cuando esa apuesta falla el prior estorba: datos tabulares sin significado espacial no ganan nada fingiendo que la columna 3 es vecina de la columna 4. Las CNNs reales también son hambrientas de datos y de cómputo; la razón por la que durmieron de 1998 a 2012 es que necesitan montones de ejemplos etiquetados y una GPU para entrenar los filtros, y nada de eso es gratis. Y solo son tolerantes a la traslación, no invariantes a rotación ni a escala: gira la imagen noventa grados y un filtro afinado para bordes horizontales está buscando lo que no es. En este capítulo nos brincamos el costo de entrenamiento por completo fijando los filtros en vez de aprenderlos, lo cual es honesto para enseñar la operación y deshonesto como retrato de lo que hace una CNN desplegada. Ten presente esa costura; vamos a volver a ella.

Los datos

Un dataset, incluido en scikit-learn: load_digits, 1,797 imágenes de dígitos manuscritos a resolución 8×8, 8 bits de escala de grises exprimidos a intensidades enteras de 0 a 16. Es el primo chico de MNIST —misma tarea, un décimo de la resolución— y es perfecto aquí precisamente por lo diminuto. Una imagen de 8×8 son 64 números, lo bastante chica como para que un filtro de 3×3 deslizándose sobre ella tenga apenas 36 paradas, así que podemos animar cada una de ellas y aun así ver el feature map llenarse a mano. Sin descarga, sin preprocesamiento, split con semilla; todo corre en un segundo.

Lo usamos de dos maneras. Para la animación del concepto escogemos una imagen —el primer 3 del conjunto— y deslizamos un solo filtro de bordes sobre ella, cuadro por cuadro. Para el resultado real corremos el banco completo de filtros sobre las 1,797 imágenes, separamos 1,257 para entrenamiento y 540 para prueba (estratificado, semilla 0), y dejamos que un clasificador lineal las acomode. Las etiquetas nunca tocan más que al clasificador; la convolución jamás las ve.

Constrúyela, una función a la vez

Cuatro funciones chicas y dos que las ensamblan, en el orden en que las escribirías. La primera es la convolución misma: la suma ponderada deslizante, exactamente la doble suma de la sección de matemáticas. Entran una imagen y un kernel; por cada posición donde el kernel cabe, sacamos el parche, multiplicamos por el kernel y sumamos a un solo número:

def conv2d(image, kernel):
    """Valid 2-D cross-correlation: slide the kernel over the image.

    For every position where the kernel fits fully inside the image, multiply
    the overlapping patch by the kernel elementwise and sum. This is what deep
    learning calls "convolution" — technically cross-correlation, since we
    don't flip the kernel; the distinction doesn't matter when the filter is
    learned, and here it keeps the arithmetic readable.

    An (H, W) image and a (kh, kw) kernel give an (H-kh+1, W-kw+1) output.
    """
    H, W = image.shape
    kh, kw = kernel.shape
    out_h, out_w = H - kh + 1, W - kw + 1
    out = np.zeros((out_h, out_w))
    for i in range(out_h):
        for j in range(out_w):
            patch = image[i:i + kh, j:j + kw]      # the receptive field
            out[i, j] = np.sum(patch * kernel)     # weighted sum, one number
    return out

Esa es la operación completa. Dos loops sobre las posiciones de salida, un parche, una multiplicación elemento a elemento, una suma. Todo lo demás en una red convolucional es esta función llamada muchas veces con distintos pesos. Sigue la no linealidad, que es casi demasiado chica para escribirla pero se gana su lugar: es lo que vuelve unilaterales las respuestas de los bordes.

def relu(x):
    """Rectified linear unit: keep positive responses, zero the rest.

    An edge filter fires positive on one polarity of edge and negative on the
    other. ReLU throws the negative half away, so each filter reports "how
    strongly this particular edge is present, and nowhere is it negatively
    present." To capture both polarities we pair every filter with its
    negation (see edge_filters); ReLU is what makes that pairing meaningful.
    """
    return np.maximum(x, 0.0)

Luego el pooling. Bloques no traslapados, el máximo de cada uno, un mapa más chico a la salida. De aquí salen tanto la tolerancia a la traslación como la reducción de features:

def max_pool2d(feature_map, size=2):
    """Downsample by taking the max over non-overlapping size x size blocks.

    Pooling buys two things at once: it shrinks the map (fewer features) and
    it makes the response tolerant to small shifts — if an edge moves by a
    pixel inside a pooling block, the max is unchanged. That translation
    tolerance is half of why convolution is the right prior for images.

    A (H, W) map with size s gives a (H//s, W//s) map; any ragged remainder
    on the right/bottom edge is dropped.
    """
    H, W = feature_map.shape
    out_h, out_w = H // size, W // size
    out = np.zeros((out_h, out_w))
    for i in range(out_h):
        for j in range(out_w):
            block = feature_map[i * size:(i + 1) * size,
                                j * size:(j + 1) * size]
            out[i, j] = block.max()
    return out

Ahora los filtros. En una CNN real estos se aprenden; aquí son un banco fijo de ocho detectores de bordes: los operadores Sobel horizontal y vertical y las dos diagonales, cada uno emparejado con su propia negación para que ReLU pueda capturar ambas polaridades de cada borde. Estos son los kernels clásicos diseñados a mano que la primera capa de una red entrenada tiende a redescubrir por su cuenta, y por eso usarlos fijos es un sustituto justo del mecanismo:

def edge_filters():
    """A fixed bank of eight 3x3 edge detectors.

    Four oriented gradients — horizontal (Sobel), vertical (Sobel), and the
    two diagonals — each paired with its negation so ReLU can capture both
    polarities of every edge. Nothing here is learned; these are the classic
    hand-designed kernels a CNN's first layer tends to rediscover on its own.
    """
    sobel_h = np.array([[1, 0, -1], [2, 0, -2], [1, 0, -1]], dtype=float)
    sobel_v = np.array([[1, 2, 1], [0, 0, 0], [-1, -2, -1]], dtype=float)
    diag_a = np.array([[0, 1, 2], [-1, 0, 1], [-2, -1, 0]], dtype=float)
    diag_b = np.array([[2, 1, 0], [1, 0, -1], [0, -1, -2]], dtype=float)
    base = [("vertical edge", sobel_h), ("horizontal edge", sobel_v),
            ("diagonal /", diag_a), ("diagonal \\", diag_b)]
    bank = []
    for name, k in base:
        bank.append((name + " +", k))
        bank.append((name + " -", -k))
    return bank

Con las piezas en la mano, el pipeline por imagen es una línea recta: corre cada filtro, rectifica, aplica pooling, aplana y concatena. Ocho filtros dan ocho mapas de 6×6; el pooling de 2×2 encoge cada uno a 3×3; aplanados y apilados eso son 8×3×3=728 \times 3 \times 3 = 72 números por imagen:

def extract_features(image, filters, pool=2):
    """Turn one image into a feature vector: conv -> ReLU -> pool, per filter.

    Run every filter over the image, rectify, pool down, flatten, and
    concatenate. Eight 3x3 filters over an 8x8 image give eight 6x6 maps;
    2x2 pooling shrinks each to 3x3, so the whole image collapses to a
    8 * 3 * 3 = 72-dimensional vector — fewer numbers than the 64 raw pixels,
    but each one summarizes an oriented edge in a small region.
    """
    parts = []
    for _name, kernel in filters:
        conv = conv2d(image, kernel)
        pooled = max_pool2d(relu(conv), pool)
        parts.append(pooled.ravel())
    return np.concatenate(parts)

Y por último el wrapper por lotes: la misma extracción sobre cada imagen, produciendo la matriz de diseño con la que entrena el clasificador. La convolución es un front end fijo, sin entrenar; todo el aprendizaje real ocurre río abajo, en la cabeza lineal:

def conv_features(images, filters=None, pool=2):
    """Stack extract_features over a batch of images into a design matrix.

    images is (N, H, W); the result is (N, F) with F the per-image feature
    count. This matrix is exactly what the linear classifier trains on — the
    convolution is a fixed, untrained front end, and all the learning happens
    in the linear head downstream.
    """
    if filters is None:
        filters = edge_filters()
    return np.array([extract_features(img, filters, pool) for img in images])

Setenta y dos features a partir de sesenta y cuatro pixeles de entrada. Apenas es una compresión, pero cada uno de esos números significa algo —la intensidad de un borde con cierta orientación en cierta región— mientras que un pixel crudo solo significa "qué tan brillante está este puntito".

Míralo trabajar

Aquí está la operación misma, en cámara lenta hasta que puedas leerla. La animación de abajo corre el conv2d real sobre un dígito —un 3 manuscrito— con el filtro de borde vertical, el kernel Sobel que responde a los cambios de brillo de izquierda a derecha. A la izquierda está la imagen de entrada, de 8×8, con el campo receptivo actual de 3×3 remarcado en naranja. A la derecha está el feature map llenándose, un pixel por cuadro, con la celda que se está escribiendo remarcada igual. El pie de imagen nombra la posición y el número exacto que el filtro calculó para ese parche.

Sigue el recuadro naranja. Empieza en el parche de arriba a la izquierda, el filtro multiplica y suma a un solo número, ese número aterriza en la esquina superior izquierda de la salida, y luego el recuadro da un paso a la derecha. Treinta y seis paradas después —seis a lo ancho, seis a lo alto— el feature map está completo, y es una imagen distinta a la entrada: brillante donde el dígito tiene un borde vertical, oscura donde está plano o donde el borde va al revés. Fíjate cómo los valores del pie de imagen se vuelven positivos en el flanco izquierdo de un trazo y negativos en el derecho: los dos lados del mismo borde. Ese signo es exactamente sobre lo que ReLU va a actuar enseguida, conservando el flanco positivo y poniendo en cero el negativo. Reinicia y córrela otra vez; es totalmente determinista, el mismo barrido cada vez, porque la convolución no es más que aritmética: sin semilla, sin aleatoriedad, una suma ponderada por posición.

Ahora la misma operación con los ocho filtros a la vez. Este es un solo dígito —el mismo 3— pasado por el banco completo, cada panel la respuesta de un filtro después de ReLU. Distintos filtros se encienden en distintos trazos:

Cada filtro es un especialista. El par de bordes verticales dispara en los trazos de arriba abajo, el par horizontal en los planos, las diagonales en los inclinados, y las dos mitades de cada par se encienden en bordes opuestos del mismo trazo porque uno es la negación del otro. Apila estas ocho vistas chicas y tienes una descripción del dígito más rica de la que los pixeles crudos te dieron jamás: no "qué tan brillante está el punto 37", sino "hay un borde vertical aquí y uno diagonal allá". Esa es la representación que recibe el clasificador.

La implementación completa

Todo el front end de convolución, sin framework, de arriba abajo. Esto es exactamente lo que corrió la animación y de donde salen los números de accuracy de más abajo:

"""Convolutional feature extraction, built from scratch.

The convolution machinery of a CNN — a filter sliding over an image, a ReLU
that keeps the positive responses, and max-pooling that shrinks the map while
keeping the strongest activation in each patch — written in pure NumPy with no
autodiff and no deep-learning framework anywhere.

The pipeline is: for each small edge-detecting filter, slide it over the image
(valid cross-correlation), rectify the response, pool it down, then flatten and
concatenate every filter's pooled map into one feature vector. That vector is
what a plain linear classifier gets trained on. We do NOT train the filters
here — they're fixed edge detectors — and we do NOT stack conv layers. This is
the convolution *operation* taught honestly, not a production training loop.

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


# region: conv2d
def conv2d(image, kernel):
    """Valid 2-D cross-correlation: slide the kernel over the image.

    For every position where the kernel fits fully inside the image, multiply
    the overlapping patch by the kernel elementwise and sum. This is what deep
    learning calls "convolution" — technically cross-correlation, since we
    don't flip the kernel; the distinction doesn't matter when the filter is
    learned, and here it keeps the arithmetic readable.

    An (H, W) image and a (kh, kw) kernel give an (H-kh+1, W-kw+1) output.
    """
    H, W = image.shape
    kh, kw = kernel.shape
    out_h, out_w = H - kh + 1, W - kw + 1
    out = np.zeros((out_h, out_w))
    for i in range(out_h):
        for j in range(out_w):
            patch = image[i:i + kh, j:j + kw]      # the receptive field
            out[i, j] = np.sum(patch * kernel)     # weighted sum, one number
    return out
# endregion


# region: relu
def relu(x):
    """Rectified linear unit: keep positive responses, zero the rest.

    An edge filter fires positive on one polarity of edge and negative on the
    other. ReLU throws the negative half away, so each filter reports "how
    strongly this particular edge is present, and nowhere is it negatively
    present." To capture both polarities we pair every filter with its
    negation (see edge_filters); ReLU is what makes that pairing meaningful.
    """
    return np.maximum(x, 0.0)
# endregion


# region: max_pool2d
def max_pool2d(feature_map, size=2):
    """Downsample by taking the max over non-overlapping size x size blocks.

    Pooling buys two things at once: it shrinks the map (fewer features) and
    it makes the response tolerant to small shifts — if an edge moves by a
    pixel inside a pooling block, the max is unchanged. That translation
    tolerance is half of why convolution is the right prior for images.

    A (H, W) map with size s gives a (H//s, W//s) map; any ragged remainder
    on the right/bottom edge is dropped.
    """
    H, W = feature_map.shape
    out_h, out_w = H // size, W // size
    out = np.zeros((out_h, out_w))
    for i in range(out_h):
        for j in range(out_w):
            block = feature_map[i * size:(i + 1) * size,
                                j * size:(j + 1) * size]
            out[i, j] = block.max()
    return out
# endregion


# region: edge_filters
def edge_filters():
    """A fixed bank of eight 3x3 edge detectors.

    Four oriented gradients — horizontal (Sobel), vertical (Sobel), and the
    two diagonals — each paired with its negation so ReLU can capture both
    polarities of every edge. Nothing here is learned; these are the classic
    hand-designed kernels a CNN's first layer tends to rediscover on its own.
    """
    sobel_h = np.array([[1, 0, -1], [2, 0, -2], [1, 0, -1]], dtype=float)
    sobel_v = np.array([[1, 2, 1], [0, 0, 0], [-1, -2, -1]], dtype=float)
    diag_a = np.array([[0, 1, 2], [-1, 0, 1], [-2, -1, 0]], dtype=float)
    diag_b = np.array([[2, 1, 0], [1, 0, -1], [0, -1, -2]], dtype=float)
    base = [("vertical edge", sobel_h), ("horizontal edge", sobel_v),
            ("diagonal /", diag_a), ("diagonal \\", diag_b)]
    bank = []
    for name, k in base:
        bank.append((name + " +", k))
        bank.append((name + " -", -k))
    return bank
# endregion


# region: extract_features
def extract_features(image, filters, pool=2):
    """Turn one image into a feature vector: conv -> ReLU -> pool, per filter.

    Run every filter over the image, rectify, pool down, flatten, and
    concatenate. Eight 3x3 filters over an 8x8 image give eight 6x6 maps;
    2x2 pooling shrinks each to 3x3, so the whole image collapses to a
    8 * 3 * 3 = 72-dimensional vector — fewer numbers than the 64 raw pixels,
    but each one summarizes an oriented edge in a small region.
    """
    parts = []
    for _name, kernel in filters:
        conv = conv2d(image, kernel)
        pooled = max_pool2d(relu(conv), pool)
        parts.append(pooled.ravel())
    return np.concatenate(parts)
# endregion


# region: conv_features
def conv_features(images, filters=None, pool=2):
    """Stack extract_features over a batch of images into a design matrix.

    images is (N, H, W); the result is (N, F) with F the per-image feature
    count. This matrix is exactly what the linear classifier trains on — the
    convolution is a fixed, untrained front end, and all the learning happens
    in the linear head downstream.
    """
    if filters is None:
        filters = edge_filters()
    return np.array([extract_features(img, filters, pool) for img in images])
# endregion

La versión de librería

Aquí no hay clasificador hecho desde cero, y es a propósito. El punto del experimento es cambiar la representación y dejar el modelo fijo, así que los dos lados del enfrentamiento usan el mismo clasificador lineal de scikit-learn —regresión logística multinomial— y la única diferencia es lo que entra. Estandariza los features, ajusta, evalúa:

def linear_head(X_train, y_train, X_test, y_test, seed=0):
    """Standardize the features, fit multinomial logistic regression, score.

    StandardScaler is fit on the training features only, then applied to both
    splits — the usual guard against leaking test statistics into training.
    LogisticRegression with the lbfgs solver is deterministic given the data,
    but we pass a seed anyway so the intent is explicit.

    Returns (test_accuracy, n_features).
    """
    scaler = StandardScaler().fit(X_train)
    Xtr = scaler.transform(X_train)
    Xte = scaler.transform(X_test)
    clf = LogisticRegression(max_iter=5000, C=1.0, random_state=seed)
    clf.fit(Xtr, y_train)
    acc = float(clf.score(Xte, y_test))
    return acc, int(X_train.shape[1])

Esa es la prueba honesta de si la convolución ayuda. Dale a esta función los 72 features convolucionales y obtienes un accuracy; dale los 64 pixeles crudos y obtienes otro; el clasificador, el solver, la regularización y la semilla son idénticos. Cualquier diferencia es la representación y nada más. Aquí también está la línea del alcance: una CNN real reemplazaría tanto los filtros fijos como esta cabeza lineal con capas entrenadas juntas por backprop en torch o tensorflow. Nosotros entrenamos solo la cabeza, encima de filtros congelados, lo cual alcanza para mostrar al prior ganándose su lugar y se queda muy corto del loop de entrenamiento real.

Desde cero contra librería

Los mismos dígitos, el mismo clasificador, el mismo split —1,257 imágenes para entrenar, 540 para probar— y lo único que cambia es si el clasificador ve pixeles crudos o features convolucionales:

Los features de convolución aterrizan en 0.9796 sobre las 540 imágenes apartadas; el mismo clasificador con pixeles crudos saca 0.9722. Es una victoria estrecha —como cuatro dígitos correctos extra de 540— y quiero tener cuidado con cuánto peso le damos. En un dataset así de chico y así de limpio, la regresión logística sobre pixeles crudos ya es fuerte, así que no hay mucho espacio arriba, y la lectura honesta es "los features fijos de bordes son competitivos con los pixeles crudos, y un poquito mejores", no "la convolución triplica tu accuracy". La diferencia se abriría con imágenes más difíciles, filtros aprendidos y más de una capa conv; aquí es un indicio, no una goleada.

El número que muestra mejor lo que pasa bajo el cofre es la tasa de descarte de ReLU. A lo largo de todas las imágenes de entrenamiento y los ocho filtros, el 52.0% de las respuestas de la convolución salen en cero o negativas y se tiran a la basura. Aproximadamente la mitad de cada feature map es silencio: el filtro miró, no encontró ningún borde de su polaridad, y no reportó nada. Eso no es desperdicio; es el punto. Cada activación que sobrevive es un "sí, este borde específico está aquí" con confianza, y el clasificador recibe una descripción dispersa y orientada en vez de un muro denso de intensidades de pixel. Menos features, y cada uno vale más.

Puntos clave

La convolución es a lo que recurres cuando los datos tienen una rejilla y los patrones con significado son locales y podrían aparecer en cualquier lado —imágenes en primer lugar, pero también audio, video y muchas series de tiempo. La razón por la que funciona no es misteriosa una vez que la construiste: es un prior, un supuesto codificado a mano de que un buen detector de características es chico y de que un patrón que vale la pena encontrar en un lugar vale la pena encontrarlo en todos. El weight sharing convierte ese supuesto en una cuenta diminuta de parámetros, el pooling lo convierte en tolerancia a pequeños desplazamientos, y el apilamiento convierte simples detectores de bordes en detectores de aquello de lo que estén hechos los datos. Cuando esos supuestos embonan con tu problema, una CNN es enormemente más eficiente en muestras que una red densa que tiene que aprender la misma feature por separado en cada ubicación; cuando no —datos tabulares planos, sin significado espacial— el prior es peso muerto y deberías buscar otra cosa.

Lo que este capítulo no hizo es la parte que vuelve realmente poderosas a las CNNs. Usamos filtros de bordes fijos y una sola etapa convolucional alimentando a un modelo lineal, porque eso aísla la operación y te deja verla. Una red convolucional real aprende sus filtros de punta a punta por backpropagation —la misma maquinaria de gradientes del capítulo del perceptrón, ahora fluyendo a través de convoluciones y pooling— y apila muchas capas para que las features se compongan en una jerarquía. Ese loop de entrenamiento vive en torch o tensorflow, en una GPU, con muchísimos más datos que 1,797 dígitos diminutos, y aquí es un no-objetivo deliberado. El mecanismo que construiste a mano es su núcleo honesto: el filtro deslizante, la respuesta rectificada, el resumen agrupado. Todo lo que una CNN profunda agrega es más de esto, aprendido en vez de fijo, que es la mejor razón posible para haber corrido primero a mano la versión fija.