18  Embeddings

Un embedding representa un objeto mediante un vector. Ese objeto puede ser una categoría, una palabra o una imagen. La pregunta decisiva es qué información conserva el vector y para qué decisión resulta útil.

Construiremos representaciones por búsqueda, por coocurrencias y por aprendizaje de imágenes. Compararemos distancias, dimensiones, categorías desconocidas, reconstrucción, clasificación y recuperación. Los ejemplos de lenguaje son introductorios: el desarrollo especializado de NLP pertenece a su libro propio.

Se requieren NumPy, pandas, Matplotlib, scikit-learn y PyTorch en CPU; todos los datos se generan o están incluidos en scikit-learn. Continúa Pipelines y prepara Redes recurrentes, Aprendizaje autosupervisado y Autoencoders.

18.1 De un identificador a un vector

Para un vocabulario de \(K\) objetos y dimensión \(d\), una tabla \(E\in\mathbb{R}^{K\times d}\) asigna al identificador \(i\) la fila \(E_i\). Los identificadores son índices: que un código sea 8 y otro 4 no indica que el primero sea el doble del segundo. Una representación one-hot multiplicada por \(E\) produce la misma fila que una consulta directa.

La tabla contiene \(Kd\) entradas. Si se aprende junto con un predictor, sus distancias responden al objetivo de entrenamiento. Una tabla inicializada al azar no adquiere significado por llamarse embedding. Tampoco la compresión garantiza mayor precisión o menor memoria: depende de la dimensión, el tipo numérico y la representación dispersa alternativa.

import numpy as np
import sklearn
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import sklearn
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import mean_squared_error, accuracy_score, balanced_accuracy_score, log_loss, ConfusionMatrixDisplay
from IPython.display import display
import torch
from torch import nn
from torch.utils.data import TensorDataset,DataLoader,WeightedRandomSampler
from torch.nn.utils.rnn import pad_sequence,pack_padded_sequence
import copy,json,math,time,platform
from pathlib import Path
torch.set_num_threads(1)
torch.use_deterministic_algorithms(True)
SEMILLA=42
COLORES=['#276FBF','#E87524','#339966','#9B59B6']
plt.rcParams.update({'figure.dpi':110,'font.size':10,'figure.constrained_layout.use':True,'axes.spines.top':False,'axes.spines.right':False})
lecturas={}
def rmse(a,b):return float(np.sqrt(mean_squared_error(a,b)))
print('Entorno preparado; NumPy antes de PyTorch, CPU y un hilo de entrenamiento.')

from sklearn.linear_model import Ridge,LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.decomposition import PCA
from sklearn.datasets import load_digits
from sklearn.metrics.pairwise import cosine_similarity,pairwise_distances
torch.manual_seed(7)
tabla=nn.Embedding(5,3)
ids=torch.tensor([1,4,1],dtype=torch.long)
onehot=torch.nn.functional.one_hot(ids,5).float()
assert torch.allclose(tabla(ids),onehot@tabla.weight)
display(pd.DataFrame(tabla.weight.detach().numpy(),columns=['e1','e2','e3']))
print('Identificadores:',ids.tolist(),'→ forma:',tuple(tabla(ids).shape),'→ parámetros:',tabla.weight.numel())
fig,ax=plt.subplots(figsize=(6,3));im=ax.imshow(tabla.weight.detach(),cmap='coolwarm');ax.set(xlabel='Coordenada',ylabel='ID',title='Tabla inicial aleatoria, todavía sin objetivo');fig.colorbar(im,ax=ax);plt.show()
Entorno preparado; NumPy antes de PyTorch, CPU y un hilo de entrenamiento.
e1 e2 e3
0 -0.146795 0.786141 0.946822
1 -1.114344 1.690790 -0.894828
2 -0.355625 1.232386 0.138173
3 -1.682199 0.317678 0.132807
4 0.137324 0.240546 1.395451
Identificadores: [1, 4, 1] → forma: (3, 3) → parámetros: 15

18.2 La distancia es una decisión

La distancia euclídea considera dirección y magnitud. La similitud coseno compara dirección y requiere vectores de norma distinta de cero:

\[s(a,b)=\frac{a^\top b}{\|a\|\|b\|}.\]

Al normalizar ambos vectores a norma uno, \(\|a-b\|^2=2-2s(a,b)\): sus rankings coinciden. Sin esa normalización pueden diferir. En un vector cero el coseno es indefinido; una biblioteca puede devolver un valor por convenio, pero debemos decidir qué significa para la aplicación.

V=np.array([[1.,0.],[2.,0.],[.9,.2],[-1.,0.]])
nombres=['A','B','C','D'];dist=pairwise_distances(V);sim=cosine_similarity(V)
display(pd.DataFrame({'objeto':nombres[1:],'distancia a A':dist[0,1:],'coseno con A':sim[0,1:]}))
fig,axs=plt.subplots(1,3,figsize=(12,3.5))
for nombre,v in zip(nombres,V):axs[0].arrow(0,0,*v,width=.015,length_includes_head=True);axs[0].text(v[0],v[1]+.08,nombre)
axs[0].set(xlim=(-1.3,2.3),ylim=(-.4,.7),title='Dirección y magnitud');axs[0].axhline(0,color='gray',lw=.5)
for ax,M,t in zip(axs[1:],[dist,sim],['Distancia euclídea','Similitud coseno']):
    im=ax.imshow(M,cmap='viridis');ax.set(xticks=range(4),xticklabels=nombres,yticks=range(4),yticklabels=nombres,title=t);fig.colorbar(im,ax=ax)
plt.show()
Q=np.array([[np.cos(.7),-np.sin(.7)],[np.sin(.7),np.cos(.7)]])
assert np.allclose(pairwise_distances(V@Q),dist)
unit=V/np.linalg.norm(V,axis=1,keepdims=True)
assert np.allclose(pairwise_distances(unit)**2,2-2*sim)
print('Más cercano a A por distancia:',nombres[1+np.argmin(dist[0,1:])],'; por coseno:',nombres[1+np.argmax(sim[0,1:])])
objeto distancia a A coseno con A
0 B 1.000000 1.000000
1 C 0.223607 0.976187
2 D 2.000000 -1.000000

Más cercano a A por distancia: C ; por coseno: B

18.2.1 Cómo leer este resultado

C está más cerca de A en distancia; B tiene la misma dirección que A. Ninguna medida es universalmente correcta. Si la magnitud representa intensidad útil, normalizar puede borrarla; si representa un efecto de escala irrelevante, conservarla puede distorsionar la búsqueda.

La rotación verificada conserva todas las distancias. Por eso no debemos atribuir un significado fijo a cada eje de un embedding sin evidencia adicional. Una proyección a dos dimensiones facilita inspección, pero puede alterar vecindades.

18.3 Categorías: aprender interacciones y reconocer lo desconocido

Generamos una respuesta continua con dos entradas numéricas y 16 categorías conocidas. Cada categoría tiene coeficientes propios: y = a_categoria × sin(x1) + b_categoria × x2 + ruido. Es una construcción controlada para observar interacciones.

El vocabulario se ajusta solo con entrenamiento. Reservamos ID 0 para desconocidos y fijamos su vector en cero mediante padding_idx=0. Aquí usamos esa opción para una fila de respaldo; no estamos confundiendo una categoría desconocida con padding de secuencias. Validación y prueba contienen categorías nuevas diferentes. No podremos aprender sus coeficientes sin observaciones o atributos adicionales.

rng=np.random.default_rng(42);coef=rng.uniform(-2,2,size=(20,2))
def crear_categorias(n,conocidas=True):
    c=rng.integers(0,16,n) if conocidas else rng.integers(16,18,n)
    x=np.column_stack([rng.uniform(-np.pi,np.pi,n),rng.normal(size=n)])
    y=coef[c,0]*np.sin(x[:,0])+coef[c,1]*x[:,1]+rng.normal(0,.1,n)
    return x,c,y
xtr,ctr,ytr=crear_categorias(1800)
xva,cva,yva=crear_categorias(400);xvu,cvu,yvu=crear_categorias(100,False)
xte,cte,yte=crear_categorias(400)
xtu=np.column_stack([rng.uniform(-np.pi,np.pi,100),rng.normal(size=100)])
ctu=rng.integers(18,20,100);ytu=coef[ctu,0]*np.sin(xtu[:,0])+coef[ctu,1]*xtu[:,1]+rng.normal(0,.1,100)
vocab={int(c):i+1 for i,c in enumerate(np.unique(ctr))}
def codificar(c):return torch.tensor([vocab.get(int(k),0) for k in c],dtype=torch.long)
escala=StandardScaler().fit(xtr)
def tensores(x,c):return torch.tensor(escala.transform(x),dtype=torch.float32),codificar(c)
Ttr=tensores(xtr,ctr);Tva=tensores(xva,cva);Tvu=tensores(xvu,cvu)
assert not set(cvu)&set(ctu) and set(vocab)==set(range(16))
display(pd.DataFrame({'partición':['entrenamiento','validación conocida','validación nueva','prueba conocida','prueba nueva'],'n':[1800,400,100,400,100],'categorías':[16,16,2,16,2]}))
fig,axs=plt.subplots(1,2,figsize=(10,3.5))
for c in range(4):
    m=ctr==c;axs[0].scatter(xtr[m,0],ytr[m],s=10,alpha=.5,label=f'C{c}')
axs[0].set(xlabel='x1',ylabel='Respuesta',title='Una categoría cambia la relación');axs[0].legend(ncol=2)
axs[1].bar(range(16),np.bincount(ctr,minlength=16));axs[1].set(xlabel='Categoría',ylabel='Observaciones',title='Frecuencia disponible para cada fila');plt.show()
partición n categorías
0 entrenamiento 1800 16
1 validación conocida 400 16
2 validación nueva 100 2
3 prueba conocida 400 16
4 prueba nueva 100 2

18.3.1 Dimensión, capacidad y criterio de selección

Entrenamos dimensiones 1, 2, 4 y 8 con el mismo predictor, semilla, lotes y 120 épocas. Elegimos cada checkpoint por RMSE de validación en categorías conocidas; después elegimos la dimensión por ese mismo criterio. La validación de categorías nuevas es un diagnóstico de respaldo, no el criterio usado para seleccionar.

La dimensión cambia el número de parámetros y la optimización. Este experimento con una semilla no demuestra que una dimensión sea superior en toda población. Tampoco repetir consultas sobre la misma validación equivale a obtener nuevas evaluaciones independientes.

Ver implementación y registro del experimento
class RegresorEmbedding(nn.Module):
    def __init__(self,d):
        super().__init__();self.embedding=nn.Embedding(17,d,padding_idx=0)
        self.red=nn.Sequential(nn.Linear(d+2,32),nn.Tanh(),nn.Linear(32,32),nn.Tanh(),nn.Linear(32,1))
    def forward(self,x,c):return self.red(torch.cat([x,self.embedding(c)],dim=1)).squeeze(1)
def predecir_cat(modelo,t):
    modelo.eval()
    with torch.no_grad():return modelo(*t).numpy()
modelos_cat={};historias_cat={};filas_cat=[]
for d in [1,2,4,8]:
    torch.manual_seed(7);modelo=RegresorEmbedding(d);opt=torch.optim.Adam(modelo.parameters(),lr=.01)
    loader=DataLoader(TensorDataset(*Ttr,torch.tensor(ytr,dtype=torch.float32)),batch_size=128,shuffle=True,generator=torch.Generator().manual_seed(7))
    mejor=np.inf;hist=[]
    for ep in range(1,121):
        modelo.train()
        for xb,cb,yb in loader:
            opt.zero_grad();loss=nn.functional.mse_loss(modelo(xb,cb),yb);loss.backward();opt.step()
        rv=rmse(yva,predecir_cat(modelo,Tva));rt=rmse(ytr,predecir_cat(modelo,Ttr));hist.append((ep,rt,rv))
        if rv<mejor:mejor=rv;estado=copy.deepcopy(modelo.state_dict());ep_mejor=ep
    modelo.load_state_dict(estado);modelos_cat[d]=modelo;historias_cat[d]=np.array(hist)
    filas_cat.append({'dimensión':d,'parámetros':sum(p.numel() for p in modelo.parameters()),'época':ep_mejor,'RMSE conocida':mejor,'RMSE nueva':rmse(yvu,predecir_cat(modelo,Tvu))})
tabla_cat=pd.DataFrame(filas_cat);display(tabla_cat)
d_cat=int(tabla_cat.loc[tabla_cat['RMSE conocida'].idxmin(),'dimensión']);elegido_cat=modelos_cat[d_cat]
assert torch.count_nonzero(elegido_cat.embedding.weight[0])==0
fig,axs=plt.subplots(1,2,figsize=(11,3.5))
for d,h in historias_cat.items():axs[0].plot(h[:,0],h[:,2],label=f'd={d}')
axs[0].set(xlabel='Época',ylabel='RMSE de validación conocida',title='La dimensión no ordena los errores');axs[0].legend()
axs[1].plot(tabla_cat['dimensión'],tabla_cat['RMSE conocida'],'o-',label='Conocidas');axs[1].plot(tabla_cat['dimensión'],tabla_cat['RMSE nueva'],'s-',label='Nuevas');axs[1].set(xlabel='Dimensión',ylabel='RMSE',title='Más capacidad no proporciona datos nuevos');axs[1].legend();plt.show()
lecturas['cat_dim']=str(d_cat);lecturas['cat_val']=f'{tabla_cat["RMSE conocida"].min():.3f}'
dimensión parámetros época RMSE conocida RMSE nueva
0 1 1234 108 0.257291 0.730263
1 2 1283 100 0.150637 3.308090
2 4 1381 92 0.138165 3.325704
3 8 1577 100 0.138152 3.044446

18.3.2 Cómo leer este resultado

Se eligió dimensión 8, con RMSE de validación conocida 0.138. Lee las curvas para distinguir aprendizaje, fluctuación y sobreajuste. El mejor checkpoint puede aparecer antes de la última época.

Las categorías nuevas comparten el vector de respaldo. Fijarlo en cero no obliga a que la red produzca la media poblacional: el predictor aún recibe las entradas numéricas, y su respuesta en ese punto depende de lo aprendido con filas conocidas. La curva de categorías nuevas no mejora de forma ordenada al aumentar dimensión. Aumentar la dimensión no informa al modelo sobre sus coeficientes. En una aplicación real conviene agregar atributos del objeto, obtener observaciones, usar un respaldo explícito o abstenerse; crear al azar una fila nueva no resuelve el problema.

18.3.3 Prueba y baselines que explican la ganancia

Comparamos la red elegida con un regresor que omite la categoría y otro con one-hot e interacciones explícitas. El segundo conoce la forma matemática del generador; es un baseline deliberadamente favorable para comprobar si una solución simple basta. Las tres alternativas se fijan antes de consultar prueba.

def bases(x):return np.column_stack([np.sin(x[:,0]),x[:,1]])
def interacciones(x,c):
    H=np.zeros((len(c),16));m=c<16;H[np.flatnonzero(m),c[m]]=1
    return np.concatenate([H*bases(x)[:,j:j+1] for j in range(2)],axis=1)
pooled=Ridge(alpha=.1).fit(bases(xtr),ytr)
oracle=Ridge(alpha=.1).fit(interacciones(xtr,ctr),ytr)
filas=[];pred_cat={}
for grupo,x,c,y in [('conocidas',xte,cte,yte),('nuevas',xtu,ctu,ytu)]:
    preds={'Sin categoría':pooled.predict(bases(x)),'One-hot + interacciones':oracle.predict(interacciones(x,c)),'Embedding + MLP':predecir_cat(elegido_cat,tensores(x,c))}
    pred_cat[grupo]=preds
    for nombre,p in preds.items():filas.append({'caso':'categorías','grupo':grupo,'modelo':nombre,'métrica':'RMSE','valor':rmse(y,p)})
res_cat=pd.DataFrame(filas);display(res_cat)
fig,axs=plt.subplots(1,2,figsize=(11,3.6))
for ax,g,y in zip(axs,['conocidas','nuevas'],[yte,ytu]):
    ax.scatter(y,pred_cat[g]['Embedding + MLP'],s=12,alpha=.6);lim=[y.min(),y.max()];ax.plot(lim,lim,'k--');ax.set(xlabel='Respuesta real',ylabel='Predicción',title=f'Prueba: categorías {g}')
plt.show()
lecturas['cat_test']=f'{rmse(yte,pred_cat["conocidas"]["Embedding + MLP"]):.3f}'
lecturas['cat_unknown']=f'{rmse(ytu,pred_cat["nuevas"]["Embedding + MLP"]):.3f}'
caso grupo modelo métrica valor
0 categorías conocidas Sin categoría RMSE 1.509573
1 categorías conocidas One-hot + interacciones RMSE 0.104445
2 categorías conocidas Embedding + MLP RMSE 0.146151
3 categorías nuevas Sin categoría RMSE 1.228480
4 categorías nuevas One-hot + interacciones RMSE 1.114662
5 categorías nuevas Embedding + MLP RMSE 2.010468

18.3.4 Cómo leer este resultado

La red obtiene RMSE 0.146 en categorías conocidas y 2.010 en nuevas. El gráfico conserva la misma tarea, pero cambia la información disponible sobre la categoría. La tabla permite contrastar esa brecha con una solución que nunca recibió IDs y con la que incorpora la ecuación del generador.

No es una demostración de superioridad del embedding frente a one-hot. Si las interacciones conocidas resuelven la tarea con menos complejidad, son una alternativa razonable. El embedding resulta útil cuando aprender interacciones compartidas aporta valor bajo el protocolo de evaluación pertinente.

En categorías conocidas, one-hot con las interacciones del generador obtiene RMSE 0.104, menor que 0.146 de la red. En nuevas, el respaldo del embedding también queda por detrás del modelo sin categoría (1.228) y del de interacciones (1.115). Para este generador conservaría la solución con interacciones en conocidos y revisaría por separado el respaldo; la dimensión elegida no justifica usar su vector desconocido sin controles.

18.3.5 Inspeccionar la tabla aprendida

Proyectaremos solo las filas conocidas. El color representa un coeficiente del generador y permite observar una relación posible; no convierte el primer componente en un estimador de ese coeficiente. Si la dimensión elegida es 1, mostramos el eje original con segunda coordenada cero.

E=elegido_cat.embedding.weight.detach().numpy()[1:]
proy=PCA(n_components=2).fit_transform(E) if E.shape[1]>1 else np.column_stack([E[:,0],np.zeros(16)])
fig,ax=plt.subplots(figsize=(7,4));im=ax.scatter(proy[:,0],proy[:,1],c=coef[:16,0],cmap='coolwarm',s=90)
for i,z in enumerate(proy):ax.annotate(f'C{i}',z,xytext=(4,4),textcoords='offset points')
ax.set(xlabel='Coordenada proyectada 1',ylabel='Coordenada proyectada 2',title='Tabla aprendida; color = coeficiente de sin(x1)');fig.colorbar(im,ax=ax,label='Coeficiente a');plt.show()

18.4 Palabras: un laboratorio pequeño de coocurrencias

Un embedding de palabras puede obtenerse sin una red. Construiremos un corpus deliberadamente regular, contaremos vecinos inmediatos y calcularemos información mutua puntual positiva:

\[\operatorname{PPMI}(w,c)=\max\left(\log\frac{P(w,c)}{P(w)P(c)},0\right).\]

Una factorización SVD produce una representación de dimensión reducida. Este ejemplo enseña la relación entre contexto y vector; no reproduce el entrenamiento de word2vec ni constituye una evaluación de lenguaje real. El corpus contiene todos los ejemplos disponibles y no tiene una prueba independiente de generalización.

corpus=[]
for par,contextos in [(['gato','perro'],['vive en casa','juega en casa','duerme en casa']),(['manzana','pera'],['crece en árbol','fruta para comer','fruta dulce fresca']),(['auto','bus'],['viaja por calle','rueda por calle','transporta por calle'])]:
    for w in par:
        for contexto in contextos:corpus.append((w+' '+contexto).split())
palabras=sorted({w for frase in corpus for w in frase});indice={w:i for i,w in enumerate(palabras)}
C=np.zeros((len(palabras),len(palabras)))
for frase in corpus:
    for j,w in enumerate(frase):
        for k in [j-1,j+1]:
            if 0<=k<len(frase):C[indice[w],indice[frase[k]]]+=1
P=C/C.sum();den=P.sum(1)[:,None]*P.sum(0)[None,:]
PPMI=np.zeros_like(P);m=P>0;PPMI[m]=np.maximum(np.log(P[m]/den[m]),0)
U,S,VT=np.linalg.svd(PPMI,full_matrices=False);W=U[:,:8]*np.sqrt(S[:8])
sim_w=cosine_similarity(W);consultas=['gato','manzana','auto'];vecinos=[]
for w in consultas:
    i=indice[w];orden=np.argsort(-sim_w[i]);orden=orden[orden!=i][:3]
    for j in orden:vecinos.append({'consulta':w,'vecino':palabras[j],'coseno':sim_w[i,j]})
display(pd.DataFrame(vecinos));print('Corpus:',len(corpus),'frases; vocabulario:',len(palabras),'palabras; dimensión:',W.shape[1])
fig,axs=plt.subplots(1,2,figsize=(12,5))
im=axs[0].imshow(PPMI,cmap='magma');axs[0].set(title='PPMI: palabra × contexto',xticks=range(len(palabras)),xticklabels=palabras,yticks=range(len(palabras)),yticklabels=palabras);axs[0].tick_params(axis='x',rotation=90);fig.colorbar(im,ax=axs[0])
objetos=['gato','perro','manzana','pera','auto','bus'];ind=[indice[w] for w in objetos]
im2=axs[1].imshow(sim_w[np.ix_(ind,ind)],cmap='viridis',vmin=-1,vmax=1)
axs[1].set(title='Coseno: comparar los seis objetos',xticks=range(6),xticklabels=objetos,yticks=range(6),yticklabels=objetos);axs[1].tick_params(axis='x',rotation=45)
for i in range(6):
    for j in range(6):axs[1].text(j,i,f'{sim_w[ind[i],ind[j]]:.2f}',ha='center',va='center',fontsize=9,color='black' if sim_w[ind[i],ind[j]]>.5 else 'white')
fig.colorbar(im2,ax=axs[1],label='Similitud coseno');plt.show()
consulta vecino coseno
0 gato perro 1.000000e+00
1 gato en 6.988066e-01
2 gato para 6.034136e-02
3 manzana pera 1.000000e+00
4 manzana en 4.739797e-01
5 manzana dulce 3.180124e-01
6 auto bus 1.000000e+00
7 auto por 1.000000e+00
8 auto calle 1.933729e-14
Corpus: 18 frases; vocabulario: 23 palabras; dimensión: 8

18.4.1 Cómo leer este resultado

La matriz de cosenos permite identificar las parejas con contextos compartidos sin depender de una proyección a dos dimensiones. Las parejas comparten contextos porque así diseñamos el corpus. La similitud observada demuestra que el procedimiento reconoce esa construcción; no demuestra comprensión ni una geometría estable en textos de otros dominios. La ventana, la frecuencia, la polisemia y los sesgos del corpus pueden cambiar los vecinos.

Una representación estática asigna la misma fila a una palabra en todos los contextos. Los modelos contextuales construyen una representación que depende de la secuencia. Ese cambio se desarrolla en Atención y transformers; tokenización avanzada y aplicaciones lingüísticas pertenecen al libro de NLP.

18.4.2 Secuencias, padding y pooling

Una secuencia de IDs con forma (N,L) se convierte en (N,L,d). Promediar sobre posiciones conserva una representación fija, pero pierde el orden. Excluir padding requiere una máscara obtenida de longitudes o del token reservado. Si contamos el padding en el denominador, el vector cambia al agregar posiciones vacías.

frases=[['gato','vive','en','casa'],['casa','en','vive','gato'],['auto','viaja']]
vocab_w={w:i+1 for i,w in enumerate(palabras)}
emb_w=nn.Embedding.from_pretrained(torch.tensor(np.vstack([np.zeros((1,8)),W]),dtype=torch.float32),freeze=True,padding_idx=0)
secuencias=[torch.tensor([vocab_w[w] for w in frase]) for frase in frases]
ids_pad=pad_sequence(secuencias,batch_first=True,padding_value=0);mascara=ids_pad.ne(0)
vectores=emb_w(ids_pad);pool=(vectores*mascara.unsqueeze(-1)).sum(1)/mascara.sum(1,keepdim=True)
assert torch.allclose(pool[0],pool[1]);ingenuo=vectores.mean(1)
print('IDs:',tuple(ids_pad.shape),'→ vectores:',tuple(vectores.shape),'→ pooling:',tuple(pool.shape))
display(pd.DataFrame({'frase':[' '.join(f) for f in frases],'longitud':mascara.sum(1).numpy(),'norma correcta':pool.norm(dim=1).numpy(),'norma contando padding':ingenuo.norm(dim=1).numpy()}))
fig,ax=plt.subplots(figsize=(7,2.8));im=ax.imshow(pool.numpy(),cmap='coolwarm');ax.set(yticks=range(3),yticklabels=['Orden original','Orden invertido','Secuencia corta'],xlabel='Dimensión',title='El promedio no distingue los dos órdenes');fig.colorbar(im,ax=ax);plt.show()
IDs: (3, 4) → vectores: (3, 4, 8) → pooling: (3, 8)
frase longitud norma correcta norma contando padding
0 gato vive en casa 4 0.798877 0.798877
1 casa en vive gato 4 0.798877 0.798877
2 auto viaja 2 1.033512 0.516756

18.5 Imágenes: reconstruir, clasificar y recuperar son tareas distintas

Usamos load_digits: 1797 imágenes de 8×8 píxeles con intensidades de 0 a 16. Dividir por 16 aplica una escala documentada; no estima estadísticas de evaluación. Separamos entrenamiento, validación y prueba con estratificación, pero la partición aleatoria no garantiza separación por escritor ni estima transferencia a otro dominio.

Un autoencoder aprenderá códigos de dimensiones 2, 8 y 16 sin recibir etiquetas. El checkpoint de cada uno se elige por MSE de reconstrucción en validación. Luego congelamos los códigos y ajustamos una regresión logística con etiquetas de entrenamiento: es una sonda lineal. Elegimos la dimensión por accuracy balanceada de validación de la sonda. Las etiquetas intervienen en esa selección, aunque no en el entrenamiento del autoencoder.

Finalmente evaluamos la representación elegida y un baseline de píxeles. Ambos tienen su escalador ajustado solo con entrenamiento. La reconstrucción puede mejorar sin facilitar clasificación: son objetivos diferentes.

Ver implementación y registro del experimento
digits=load_digits();imagenes=digits.data.astype('float32')/16;etiquetas=digits.target
itr,rest=train_test_split(np.arange(len(imagenes)),test_size=.4,stratify=etiquetas,random_state=42)
iva,ite=train_test_split(rest,test_size=.5,stratify=etiquetas[rest],random_state=42)
A=[torch.tensor(imagenes[i]) for i in [itr,iva,ite]]
class Autoencoder(nn.Module):
    def __init__(self,d):
        super().__init__();self.encoder=nn.Sequential(nn.Linear(64,32),nn.ReLU(),nn.Linear(32,d))
        self.decoder=nn.Sequential(nn.Linear(d,32),nn.ReLU(),nn.Linear(32,64),nn.Sigmoid())
    def forward(self,x):return self.decoder(self.encoder(x))
def codigos(modelo,x):
    modelo.eval()
    with torch.no_grad():return modelo.encoder(x).numpy()
ae_modelos={};sondas={};filas_ae=[];hist_ae={}
for d in [2,8,16]:
    torch.manual_seed(11);ae=Autoencoder(d);opt=torch.optim.Adam(ae.parameters(),lr=.01)
    loader=DataLoader(TensorDataset(A[0]),batch_size=128,shuffle=True,generator=torch.Generator().manual_seed(11))
    mejor=np.inf;h=[]
    for ep in range(1,46):
        ae.train()
        for (xb,) in loader:opt.zero_grad();loss=nn.functional.mse_loss(ae(xb),xb);loss.backward();opt.step()
        ae.eval()
        with torch.no_grad():rv=float(nn.functional.mse_loss(ae(A[1]),A[1]));rt=float(nn.functional.mse_loss(ae(A[0]),A[0]))
        h.append((ep,rt,rv))
        if rv<mejor:mejor=rv;estado=copy.deepcopy(ae.state_dict());mejor_ep=ep
    ae.load_state_dict(estado);ae_modelos[d]=ae;hist_ae[d]=np.array(h)
    sonda=make_pipeline(StandardScaler(),LogisticRegression(C=1,max_iter=2000)).fit(codigos(ae,A[0]),etiquetas[itr]);sondas[d]=sonda
    bal=balanced_accuracy_score(etiquetas[iva],sonda.predict(codigos(ae,A[1])))
    filas_ae.append({'dimensión':d,'parámetros AE':sum(p.numel() for p in ae.parameters()),'época':mejor_ep,'MSE reconstrucción validación':mejor,'accuracy balanceada sonda validación':bal})
tabla_ae=pd.DataFrame(filas_ae);display(tabla_ae)
d_img=int(tabla_ae.loc[tabla_ae['accuracy balanceada sonda validación'].idxmax(),'dimensión']);ae=ae_modelos[d_img];sonda=sondas[d_img]
fig,axs=plt.subplots(1,2,figsize=(11,3.5))
for d,h in hist_ae.items():axs[0].plot(h[:,0],h[:,2],label=f'd={d}')
axs[0].set(xlabel='Época',ylabel='MSE de validación',title='Reconstrucción');axs[0].legend()
axs[1].plot(tabla_ae['dimensión'],tabla_ae['accuracy balanceada sonda validación'],'o-');axs[1].set(xlabel='Dimensión',ylabel='Accuracy balanceada',title='Información accesible a una sonda lineal');plt.show()
lecturas['img_dim']=str(d_img)
dimensión parámetros AE época MSE reconstrucción validación accuracy balanceada sonda validación
0 2 4354 45 0.038827 0.692381
1 8 4744 45 0.018574 0.907698
2 16 5264 44 0.013599 0.943889

18.5.1 Cómo leer este resultado

La selección por clasificación elige dimensión 16. Compara las dos gráficas antes de atribuir utilidad al código: un menor error de píxel no es una medida de separación de clases. La sonda también tiene límites: un código útil para un clasificador no lineal puede recibir una puntuación modesta con una sonda lineal.

18.5.2 Inspección visual y evaluación reservada

Mostramos seis imágenes de prueba fijadas por su posición, sin escoger las reconstrucciones más favorables. La proyección PCA se ajusta en códigos de entrenamiento y solo transforma prueba. Los colores son etiquetas para interpretar, no entradas del autoencoder.

Ztr=codigos(ae,A[0]);Zte=codigos(ae,A[2])
pixel=make_pipeline(StandardScaler(),LogisticRegression(C=1,max_iter=2000)).fit(imagenes[itr],etiquetas[itr])
res_img=[]
for nombre,p in [('Píxeles + sonda lineal',pixel.predict(imagenes[ite])),('Embedding + sonda lineal',sonda.predict(Zte))]:
    res_img.append({'caso':'imágenes','grupo':'prueba','modelo':nombre,'métrica':'accuracy balanceada','valor':balanced_accuracy_score(etiquetas[ite],p)})
display(pd.DataFrame(res_img))
with torch.no_grad():recon=ae(A[2]).numpy()
fig,axs=plt.subplots(2,6,figsize=(11,4))
for j in range(6):
    axs[0,j].imshow(imagenes[ite[j]].reshape(8,8),cmap='gray',vmin=0,vmax=1);axs[0,j].set_title(f'Real: {etiquetas[ite[j]]}')
    axs[1,j].imshow(recon[j].reshape(8,8),cmap='gray',vmin=0,vmax=1);axs[1,j].set_title('Reconstrucción')
    axs[0,j].axis('off');axs[1,j].axis('off')
plt.show()
pca=PCA(n_components=2).fit(Ztr);proy=pca.transform(Zte)
fig,ax=plt.subplots(figsize=(7,4));im=ax.scatter(proy[:,0],proy[:,1],c=etiquetas[ite],cmap='tab10',vmin=-.5,vmax=9.5,s=15,alpha=.7);ax.set(xlabel='PC1',ylabel='PC2',title='Proyección de códigos de prueba');fig.colorbar(im,ax=ax,ticks=range(10),label='Dígito');plt.show()
lecturas['img_test']=f'{res_img[1]["valor"]:.3f}';lecturas['img_pixel']=f'{res_img[0]["valor"]:.3f}'
caso grupo modelo métrica valor
0 imágenes prueba Píxeles + sonda lineal accuracy balanceada 0.963642
1 imágenes prueba Embedding + sonda lineal accuracy balanceada 0.927505

18.5.3 Cómo leer este resultado

La sonda sobre embeddings alcanza accuracy balanceada 0.928; el baseline de píxeles alcanza 0.964. Esta comparación incorpora la información descartada por la compresión y el objetivo de reconstrucción. En esta ejecución gana el baseline de píxeles por aproximadamente 3.6 puntos porcentuales. El código de 16 dimensiones reduce la entrada a una cuarta parte, pero esa compresión tiene un costo de clasificación en esta prueba; no se justifica presentarla como una mejora de precisión.

PCA muestra solo dos componentes. Clases que se superponen en la figura pueden separarse en dimensiones descartadas, y grupos visuales no prueban por sí solos utilidad predictiva. Las reconstrucciones revelan detalles borrados y trazos suavizados que una media de MSE oculta.

18.6 Recuperación: vecinos con referencias separadas

Cada consulta de prueba busca cinco vecinos únicamente entre imágenes de entrenamiento. Fijamos k=5 antes de consultar las etiquetas de prueba. Comparamos distancia euclídea y coseno como diagnóstico declarado; no elegimos retrospectivamente una medida a partir de esta prueba.

La precisión@5 es la proporción de referencias que comparten el dígito de la consulta, promediada sobre consultas. No es accuracy de clasificación ni recall. El uso de etiquetas define qué consideramos relevante; en una búsqueda por estilo de escritura el criterio podría ser distinto.

assert np.all(np.linalg.norm(Ztr,axis=1)>0) and np.all(np.linalg.norm(Zte,axis=1)>0)
vecinos_img={};res_rec=[]
for medida in ['euclidean','cosine']:
    distancias=pairwise_distances(Zte,Ztr,metric=medida);nn_idx=np.argsort(distancias,axis=1)[:,:5];vecinos_img[medida]=nn_idx
    precision=float(np.mean(etiquetas[itr][nn_idx]==etiquetas[ite,None]))
    res_rec.append({'caso':'recuperación','grupo':'prueba','modelo':medida,'métrica':'precisión@5','valor':precision})
display(pd.DataFrame(res_rec))
fig,axs=plt.subplots(2,6,figsize=(11,4))
for fila,q in enumerate([0,1]):
    axs[fila,0].imshow(imagenes[ite[q]].reshape(8,8),cmap='gray',vmin=0,vmax=1);axs[fila,0].set_title(f'Consulta: {etiquetas[ite[q]]}');axs[fila,0].axis('off')
    for j,r in enumerate(vecinos_img['euclidean'][q],start=1):
        axs[fila,j].imshow(imagenes[itr[r]].reshape(8,8),cmap='gray',vmin=0,vmax=1);axs[fila,j].set_title(f'Vecino: {etiquetas[itr[r]]}');axs[fila,j].axis('off')
plt.show()
resultados=pd.concat([res_cat,pd.DataFrame(res_img),pd.DataFrame(res_rec)],ignore_index=True);resultados.to_csv('resultados.csv',index=False)
lecturas['rec_e']=f'{res_rec[0]["valor"]:.3f}';lecturas['rec_c']=f'{res_rec[1]["valor"]:.3f}'
protocolo={'semilla':42,'categorias':{'dimension':d_cat,'seleccion':'RMSE de validación conocida','desconocido':0},'imagenes':{'dimension':d_img,'seleccion':'accuracy balanceada de sonda en validación','train':itr.tolist(),'validacion':iva.tolist(),'prueba':ite.tolist()},'recuperacion':{'k':5,'referencias':'solo entrenamiento','medidas':['euclidean','cosine']},'versiones':{'numpy':np.__version__,'sklearn':sklearn.__version__,'torch':torch.__version__}}
Path('protocolo.json').write_text(json.dumps(protocolo,ensure_ascii=False,indent=2),encoding='utf-8')
caso grupo modelo métrica valor
0 recuperación prueba euclidean precisión@5 0.938333
1 recuperación prueba cosine precisión@5 0.935556

20987

18.6.1 Cómo leer este resultado

La precisión@5 es 0.938 con distancia euclídea y 0.936 con coseno. Una diferencia indica que magnitud y orientación ordenan vecinos de manera distinta. Las dos primeras consultas muestran qué aspecto tienen esos vecinos; la tabla usa todas las consultas y evita concluir a partir de dos ejemplos.

Las consultas comparten las mismas referencias y proceden de una única partición. Estas cifras son descriptivas de este experimento; no justifican afirmar una superioridad general ni estimar confianza tratando cada vecino como una observación independiente.

18.7 Decisiones que debe sostener la representación

Evidencia Interpretación posible Decisión o comprobación
Buen error en conocidos y malo en nuevos Falta información sobre objetos nuevos Atributos adicionales y política de respaldo
Más dimensiones sin mejor validación Capacidad o detalle innecesario para la tarea Preferir dimensión pequeña tras repetir semillas
Reconstrucción buena y sonda débil El objetivo conserva píxeles sin separar etiquetas Revisar objetivo o evaluar otra sonda
PCA atractiva y métricas pobres La proyección es una vista parcial Medir en el espacio original
Vecinos cambian al normalizar La magnitud influye en el ranking Definir relevancia y validar distancia
Pooling confunde órdenes Se descartó estructura secuencial Encoder de secuencias o atención

Un embedding reutilizable debe viajar con su vocabulario, transformación de entrada, dimensión, versión y reglas para desconocidos. En representaciones entrenadas con etiquetas también hay que registrar qué tarea definió su geometría. La transferencia se valida con datos de la nueva tarea.

18.7.1 Qué actualiza el entrenamiento y qué puede transferirse

Una consulta a la tabla devuelve filas; al retropropagar, las categorías presentes en el lote reciben gradientes de ese uso. Categorías frecuentes suelen acumular más oportunidades de actualización. El gradiente procede de la tarea: dos IDs pueden acercarse porque permiten predicciones parecidas, aunque representen objetos distintos en otro contexto. La frecuencia y la regularización deben analizarse junto con la geometría.

Representación Cómo se obtiene Qué se conserva al reutilizarla
Tabla de categorías Supervisión de una tarea y vocabulario de IDs Tabla, vocabulario, significado de IDs y respaldo
Palabras estáticas Contextos o un objetivo lingüístico Vocabulario, segmentación y convenciones de consulta
Secuencias contextuales Encoder que usa la secuencia completa o su pasado Encoder, tokenización, máscara y regla de pooling
Imágenes Reconstrucción, supervisión o contraste Encoder, escala, tamaño, canales y transformación

Congelar un encoder mantiene sus parámetros, pero no garantiza utilidad en otro dominio. Ajustarlo modifica su geometría: referencias almacenadas para búsqueda pueden quedar incompatibles con consultas nuevas. En un índice de recuperación hay que conservar la versión del encoder y regenerar referencias cuando corresponda.

Para una tabla con un millón de objetos y 64 coordenadas float32, solo las entradas de la tabla requieren 256 MB decimales; gradientes y estado del optimizador añaden memoria. Esta cuenta no incluye el resto de la red. La dimensión se decide considerando utilidad, costo y estabilidad, no mediante una fórmula universal.

18.8 Actividades de análisis

  1. Repite el experimento categórico con frecuencias muy desiguales. Compara error por categoría y tamaño de muestra antes de aumentar dimensión.
  2. Cambia el respaldo de desconocidos y define una validación con categorías completamente nuevas. Explica qué información habilita cada opción.
  3. Introduce una palabra en dos familias de contextos del corpus. Observa sus vecinos y distingue polisemia de ruido.
  4. Evalúa una sonda no lineal sobre los códigos de imagen sin modificar el autoencoder. Selecciona con validación y reserva una prueba nueva si ya inspeccionaste esta.
  5. Compara recuperación por dígito y por estilo. Define etiquetas de relevancia antes de elegir una distancia.

18.9 Contrato común y persistencia

Los índices de vocabulario, desconocidos y padding forman parte de la entrada. Un embedding es un parámetro aprendido, no una distancia semántica certificada. Se conservan tareas y particiones del capítulo, y las visualizaciones se interpretan como diagnósticos de esta muestra.

Esta ejecución conserva el desarrollo didáctico y las recetas del capítulo. El cierre registra entorno, tablas y pesos en artefactos_pytorch/. Los estados serializados se recargan en una copia de la arquitectura y se comprueba identidad de parámetros y buffers; esa comprobación de persistencia no sustituye una evaluación de inferencia ni demuestra calidad predictiva. Los pesos guardados no incluyen todos los estados necesarios para reanudar exactamente el experimento.

from pathlib import Path
import json,hashlib,copy,shutil,platform
import torch
artefactos_cierre=Path('artefactos_pytorch');artefactos_cierre.mkdir(exist_ok=True)
modulos_cierre=[];vistos_cierre=set()
for nombre_cierre,objeto_cierre in list(globals().items()):
    if isinstance(objeto_cierre,torch.nn.Module) and id(objeto_cierre) not in vistos_cierre:
        vistos_cierre.add(id(objeto_cierre));estado_cierre={k:v.detach().cpu().clone() for k,v in objeto_cierre.state_dict().items()}
        assert all(torch.isfinite(v).all() for v in estado_cierre.values() if v.is_floating_point()),nombre_cierre
        ruta_cierre=artefactos_cierre/f'{nombre_cierre}-estado.pt';torch.save({'state_dict':estado_cierre},ruta_cierre)
        copia_cierre=copy.deepcopy(objeto_cierre).cpu();copia_cierre.load_state_dict(torch.load(ruta_cierre,map_location='cpu',weights_only=True)['state_dict'])
        assert all(torch.equal(v.cpu(),estado_cierre[k]) for k,v in copia_cierre.state_dict().items())
        modulos_cierre.append({'variable':nombre_cierre,'clase':type(objeto_cierre).__name__,'parametros':sum(p.numel() for p in objeto_cierre.parameters()),'recarga_estado':True})
for nombre_cierre,objeto_cierre in list(globals().items()):
    if isinstance(objeto_cierre,pd.DataFrame) and len(objeto_cierre)<100000:objeto_cierre.to_csv(artefactos_cierre/f'tabla-{nombre_cierre}.csv',index=False)
for ruta_cierre in list(Path('.').iterdir()):
    if ruta_cierre.is_file() and ruta_cierre.suffix in ['.csv','.json','.joblib']:shutil.copy2(ruta_cierre,artefactos_cierre/ruta_cierre.name)
registro_cierre={'python':platform.python_version(),'torch':str(torch.__version__),'numpy':np.__version__,'sklearn':sklearn.__version__,'modulos':modulos_cierre,'alcance':'identidad de parámetros/buffers al recargar; no reanudación completa','archivos':{p.name:hashlib.sha256(p.read_bytes()).hexdigest() for p in artefactos_cierre.iterdir() if p.is_file()}}
(artefactos_cierre/'cierre-armonizacion.json').write_text(json.dumps(registro_cierre,ensure_ascii=False,indent=2),encoding='utf-8')
print('Estados recargados y verificados:',len(modulos_cierre),'| archivos registrados:',len(registro_cierre['archivos']))
Estados recargados y verificados: 4 | archivos registrados: 10

18.9.1 Lectura de la ejecución registrada

Ejecución CPU del 15 de octubre de 2026.

En categorías conocidas, embedding+MLP obtuvo RMSE 0,1462 frente a 0,1044 de one-hot con interacciones; en categorías nuevas, 2,0105 frente a 1,1147 y 1,2285 de la referencia sin categoría. El embedding no aseguró ventaja ni una salida útil para IDs nuevos. En imágenes, la sonda sobre píxeles alcanzó exactitud balanceada 0,9636 y la del embedding 0,9275. Recuperación precisión@5 fue 0,9383 euclídea y 0,9356 coseno. Se conserva el desarrollo sobre geometría y representaciones sin atribuir significado semántico universal a distancias o PCA.

La armonización verificó identidad de parámetros y buffers tras recarga de 4 módulos presentes al cierre; archivó 10 archivos con SHA-256. Esta prueba no incluye reanudación exacta del optimizador ni sustituye las evaluaciones predictivas del capítulo.

18.10 Referencias y archivos