La lección sobre sistemas lineales estableció que un sistema admite cero, una o infinitas soluciones, y que el criterio de rango decide cuál de los tres casos se da. El caso de solución única quedó resuelto con np.linalg.solve. Falta el tercero: cuando las soluciones son infinitas, describirlas todas y entender cuál devuelve el código.
Un sistema subdeterminado
Cuando tiene menos ecuaciones que incógnitas, , el rango no puede superar , de modo que necesariamente. Si el sistema es compatible, las soluciones son infinitas.
import numpy as np
A = np.array([[1., 0., 8., -4.],
[0., 1., 2., 12.]])
b = np.array([42., 8.])
A.shape # (2, 4): dos ecuaciones, cuatro incógnitas
np.linalg.matrix_rank(A) # 2
np.linalg.solve no es aplicable aquí: exige una matriz cuadrada y no singular. La función que opera sobre sistemas rectangulares es np.linalg.lstsq.
Una solución particular
xp, *_ = np.linalg.lstsq(A, b, rcond=None)
xp # array([ 0.5898, 0.1804, 5.0789, -0.1948])
np.allclose(A @ xp, b) # True
El vector devuelto satisface el sistema, pero no es la única posibilidad. Se denomina solución particular, , precisamente porque es una entre infinitas. Describirlas todas requiere un objeto adicional.
El núcleo
El núcleo de , también llamado espacio nulo, es el conjunto de vectores que envía al origen:
Es un subespacio vectorial: contiene al y es cerrado bajo suma y producto por escalar. Su relevancia aquí es inmediata. Si y , entonces por linealidad
Sumar cualquier elemento del núcleo a una solución produce otra solución. Y el recíproco también se cumple: si y son ambas soluciones, su diferencia satisface , luego pertenece al núcleo.
De ahí la caracterización completa del conjunto solución:
No es un subespacio —salvo que , no contiene al origen— sino un subespacio afín: un subespacio trasladado por .
El teorema del rango
La dimensión del núcleo no es arbitraria. El teorema del rango la fija:
La lectura es directa: de las dimensiones del dominio, sobreviven a la transformación y el resto se colapsa sobre el origen. En el ejemplo, , de modo que el conjunto solución es un plano afín dentro de .
from scipy.linalg import null_space
N = null_space(A) # (4, 2): las columnas son una base ortonormal
np.allclose(A @ N, 0) # True
null_space no introduce ningún método nuevo: calcula la descomposición en valores singulares y retiene las direcciones cuyo valor singular es nulo. El equivalente en NumPy explicita ese mecanismo:
U, s, Vt = np.linalg.svd(A)
tol = max(A.shape) * np.finfo(float).eps * s[0]
N = Vt[(s > tol).sum():].T # columnas = base del núcleo
La base que devuelve es ortonormal, propiedad que no es exigible a un núcleo cualquiera pero que la descomposición proporciona sin coste adicional y que simplifica los cálculos posteriores.
La solución general
Con y una base del núcleo, toda solución se escribe
c = np.array([3.0, -1.5]) # coeficientes arbitrarios
x = xp + N @ c
np.allclose(A @ x, b) # True
np.linalg.norm(x) # mayor que ‖xp‖
Cualquier elección de coeficientes produce una solución válida. La comprobación A @ x == b se cumple para todas ellas por igual, y por tanto no sirve para distinguirlas.
Cuál elige el código
Si todas son soluciones, la pregunta es qué criterio adicional aplica lstsq al devolver una sola. La respuesta es la norma mínima: entre todas, devuelve la de menor .
La figura reduce la situación al caso más pequeño posible, una ecuación con dos incógnitas, donde el conjunto solución es una recta:
Todos los puntos de la recta violeta resuelven el sistema. El deslizador recorre el núcleo.
x = (1.87, 1.06)
A·x − b = 0e+0
‖x‖ = 2.154
‖x‖² = ‖x*‖² + c², porque x* es ortogonal al núcleo. El mínimo está en c = 0, y solo ahí.
El residuo permanece nulo a lo largo de toda la recta, mientras que alcanza un mínimo en un único punto: el pie de la perpendicular desde el origen, marcado en color verde azulado.
Esa observación geométrica admite una demostración de una línea. La solución de norma mínima es ortogonal al núcleo, de modo que por el teorema de Pitágoras
para todo , con igualdad únicamente si . La solución de norma mínima es la componente de cualquier solución en el espacio fila de , que es el complemento ortogonal del núcleo.
Aplicación: sobreparametrización
Una red neuronal moderna tiene con frecuencia más parámetros que ejemplos de entrenamiento. La situación es exactamente la de esta lección: el sistema está subdeterminado y existen infinitas configuraciones de pesos que ajustan los datos de entrenamiento igual de bien.
Que el modelo generalice o no depende, por tanto, de cuál de esas infinitas soluciones selecciona el entrenamiento. La respuesta no está en la función de pérdida, que las valora a todas por igual, sino en el algoritmo de optimización.
Para el caso lineal el resultado es demostrable. Si el descenso por gradiente se inicializa en , cada actualización añade un múltiplo de , que pertenece al espacio fila de . Los iterados permanecen en ese subespacio, y el límite es precisamente la solución de norma mínima:
w = np.zeros(4)
for _ in range(2000):
w -= 0.002 * A.T @ (A @ w - b) # descenso por gradiente desde cero
np.allclose(w, xp, atol=1e-3) # converge a la solución de lstsq
El optimizador introduce así una preferencia que nadie escribió en el objetivo. Se denomina sesgo implícito, y actúa como una regularización que no aparece en la función de pérdida.
Conviene precisar el alcance de esta afirmación. El resultado está demostrado para modelos lineales y para ciertos casos con función de pérdida cuadrática; en redes profundas con activaciones no lineales, el sesgo implícito del descenso por gradiente es objeto de investigación activa y no se reduce a la norma mínima euclídea. La analogía es útil para entender por qué la sobreparametrización no implica necesariamente sobreajuste, pero no constituye una explicación completa de la generalización.
Ejercicio. Calcular np.linalg.norm(xp) y compararlo con la norma de xp + N @ c para varios valores de c. Comprobar que la diferencia de los cuadrados coincide con , y explicar qué propiedad de la base devuelta por null_space hace que ese cálculo se reduzca a .