Caso de estudio
7.1 CASO CNN
7.1.1 Arquitectura de la red neuronal convolucional
La arquitectura propuesta recibe imágenes de tamaño \(28\times28\times1\) y está compuesta por tres bloques convolucionales seguidos de un clasificador denso.
model = tf.keras.Sequential([
tf.keras.layers.Input(shape=(28,28,1)),
tf.keras.layers.Conv2D(32,3,activation='relu'),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.MaxPooling2D(),
tf.keras.layers.SpatialDropout2D(0.25),
tf.keras.layers.Conv2D(64,3,activation='relu'),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.MaxPooling2D(),
tf.keras.layers.SpatialDropout2D(0.25),
tf.keras.layers.Conv2D(128,3,activation='relu'),
tf.keras.layers.MaxPooling2D(),
tf.keras.layers.SpatialDropout2D(0.25),
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(256, activation='relu'),
tf.keras.layers.Dropout(0.5),
tf.keras.layers.Dense(num_classes, activation='softmax')
])7.1.2 Primer bloque convolucional
La primera capa convolucional emplea 32 filtros de tamaño \(3\times3\):
tf.keras.layers.Conv2D(32,3,activation='relu')Este bloque se encarga de detectar características de bajo nivel, tales como bordes, esquinas y pequeños segmentos de los trazos.
La incorporación de una capa de normalización por lotes:
tf.keras.layers.BatchNormalization()permite estabilizar la distribución de las activaciones internas, acelerando el proceso de convergencia.
Posteriormente, la operación de max pooling
tf.keras.layers.MaxPooling2D()reduce la dimensionalidad espacial de las representaciones, disminuyendo el número de parámetros y haciendo al modelo menos sensible a pequeñas traslaciones.
Finalmente, se incorpora un mecanismo de regularización:
tf.keras.layers.SpatialDropout2D(0.25)que desactiva mapas de características completos de manera aleatoria, obligando a la red a aprender representaciones más robustas.
7.1.3 Segundo y tercer bloque convolucional
Los bloques posteriores incrementan progresivamente el número de filtros hasta 64 y 128, respectivamente. El aumento en la profundidad de la red permite capturar patrones cada vez más complejos, tales como combinaciones de curvas, intersecciones y configuraciones geométricas características de cada glifo.
7.1.4 Clasificador
Las características extraídas se transforman en un vector unidimensional:
tf.keras.layers.Flatten()y posteriormente se procesan mediante una capa densa:
tf.keras.layers.Dense(256, activation='relu')La capa de salida utiliza la función Softmax:
tf.keras.layers.Dense(num_classes, activation='softmax')la cual produce una distribución de probabilidad sobre todas las clases del alfabeto.
7.2 Entrenamiento
El modelo se compiló utilizando el optimizador Adam:
model.compile(
optimizer='adam',
loss='sparse_categorical_crossentropy',
metrics=['accuracy']
)La función de pérdida de entropía cruzada dispersa se seleccionó debido a que las etiquetas de las imágenes se encuentran codificadas como enteros y no mediante vectores one-hot.
Para evitar el sobreajuste se implementó un mecanismo de detención temprana:
early_stop = EarlyStopping(
monitor='val_loss',
patience=7,
restore_best_weights=True
)La elección de siete épocas de paciencia permite al modelo continuar el aprendizaje ante pequeñas oscilaciones de la pérdida de validación, evitando finalizar el entrenamiento de forma prematura.
Asimismo, el mejor modelo obtenido se almacenó automáticamente:
checkpoint = ModelCheckpoint(
ruta_modelo,
monitor='val_loss',
save_best_only=True
)7.3 Sistema de inferencia interactivo
Finalmente, se desarrolló una interfaz interactiva en Google Colab que permite dibujar caracteres del alfabeto Náhuatl y obtener predicciones en tiempo real.
La imagen generada por el usuario se redimensiona a \(28\times28\) píxeles:
img_resized = cv2.resize(img, (28,28))
img_normalized = img_resized / 255.0
img_input = np.expand_dims(img_normalized, axis=(0,-1))Posteriormente, el modelo produce un vector de probabilidades:
predicciones = model.predict(img_input)y se determina la clase más probable mediante:
idx_ganador = np.argmax(predicciones)Además de la predicción principal, la interfaz presenta las tres clases con mayor probabilidad, proporcionando información adicional sobre la confianza y las posibles ambigüedades del modelo.
7.4 CASO HMM
7.5 CASO HMM
En este capítulo se describe la metodología implementada para el reconocimiento automático de letras mayúsculas manuscritas del alfabeto latino mediante Modelos Ocultos de Markov (HMM). A diferencia del capítulo anterior, los bloques de código se muestran con fines documentales y no son ejecutados automáticamente dentro del documento debido al alto costo computacional del entrenamiento.
7.5.1 Configuración del entorno experimental
El primer paso consistió en importar las bibliotecas necesarias para el procesamiento de imágenes, manipulación de datos, entrenamiento de los modelos HMM y evaluación de resultados, estableciendo una conexión con un sistema de almacenamiento externo:
from google.colab import drive
drive.mount('/content/drive')
import cv2
import numpy as np
import torchvision.datasets as datasets
import torchvision.transforms as transforms
import matplotlib.pyplot as plt
import seaborn as sns
import random
import os
import pickle
from tqdm import tqdm
try:
from hmmlearn import hmm
except ImportError:
import subprocess
import sys
subprocess.check_call([sys.executable, "-m", "pip", "install", "hmmlearn"])
from hmmlearn import hmm
from sklearn.metrics import accuracy_score, confusion_matrix, classification_report
import warnings Para asegurar la reproducibilidad de los resultados, se fijaron semillas aleatorias y se deshabilitaron advertencias no críticas:
np.random.seed(42)
random.seed(42)
warnings.filterwarnings("ignore") 7.5.2 Definición de parámetros del experimento
A continuación, se definieron los parámetros principales utilizados durante el entrenamiento y la evaluación del sistema:
N_ESTADOS = 12
TRAIN_POR_CLASE = 1500
TEST_POR_CLASE = 400
IMG_SIZE = 40
UMBRAL = 0.30
ARCHIVO_MODELOS_CAT = "/content/drive/MyDrive/modelos_categorical_v_avanzada_hmm.pkl"
TARGETS_MAYUSCULAS = list(range(10,36))
MAPEO_LETTERS = {i: chr(ord('A') + (i - 10)) for i in range(10,36)}
LETRAS_OBJETIVO = [chr(ord('A') + i) for i in range(26)]Se optó por utilizar 12 estados ocultos para equilibrar la capacidad descriptiva y el costo computacional, seleccionando 1500 muestras de entrenamiento y 400 de prueba por clase con un tamaño de imagen fijado en \(40\times40\) píxeles.
7.5.3 Obtención del conjunto de datos
Para el entrenamiento y evaluación del sistema se empleó el conjunto de datos EMNIST ByClass:
print("Descargando EMNIST ByClass...")
emnist_train = datasets.EMNIST(root='./data', split='byclass',
train=True, download=True,
transform=transforms.ToTensor())
emnist_test = datasets.EMNIST(root='./data', split='byclass',
train=False, download=True,
transform=transforms.ToTensor())De este conjunto se seleccionaron exclusivamente las letras mayúsculas con el propósito de acotar el problema de clasificación.
7.5.4 Arquitectura del HMM
Se construyó una matriz de transición tipo Bakis flexible para modelar la evolución progresiva de los trazos:
def construir_matriz_bakis_flexible(n_states):
A = np.zeros((n_states, n_states))
for i in range(n_states):
if i == n_states - 1:
A[i, i] = 1.0
elif i == n_states - 2:
A[i, i] = 0.60
A[i, i+1] = 0.40
else:
A[i, i] = 0.60
A[i, i+1] = 0.30
A[i, i+2] = 0.10
return ALa estructura Bakis impone una restricción direccional que permite permanecer en el mismo estado o avanzar, evitando retrocesos, lo cual resulta adecuado para describir procesos secuenciales como la formación de trazos manuscritos.
7.5.5 Preprocesamiento de imágenes
Antes de convertir las imágenes en secuencias observables, se aplicó un proceso de normalización y alineamiento:
def procesar_imagen(imagen_tensor):
imagen = imagen_tensor.squeeze().numpy()
imagen = np.fliplr(imagen)
imagen = np.rot90(imagen)
img_bin = (imagen > UMBRAL).astype(np.uint8)
filas = np.any(img_bin, axis=1)
columnas = np.any(img_bin, axis=0)
if np.any(filas) and np.any(columnas):
ymin, ymax = np.where(filas)[0][[0,-1]]
xmin, xmax = np.where(columnas)[0][[0,-1]]
recorte = imagen[ymin:ymax+1, xmin:xmax+1]
h, w = recorte.shape
max_dim = max(h, w)
pad_h = max_dim - h
pad_w = max_dim - w
pad_top = pad_h // 2
pad_bottom = pad_h - pad_top
pad_left = pad_w // 2
pad_right = pad_w - pad_left
recorte_cuadrado = np.pad(recorte, ((pad_top, pad_bottom),
(pad_left, pad_right)),
mode='constant', constant_values=0)
imagen = cv2.resize(recorte_cuadrado.astype(np.float32),
(IMG_SIZE, IMG_SIZE),
interpolation=cv2.INTER_LINEAR)
else:
imagen = cv2.resize(imagen.astype(np.float32), (IMG_SIZE, IMG_SIZE),
interpolation=cv2.INTER_LINEAR)
imagen = (imagen > UMBRAL).astype(float)
return imagenEste procedimiento incluyó la corrección de orientación del conjunto EMNIST, la eliminación de espacios vacíos mediante recortes, relleno para conservar proporciones y redimensionamiento uniforme.
7.5.6 Extracción de características
Cada imagen preprocesada fue transformada en una secuencia discreta de observaciones mediante ventanas deslizantes verticales:
def imagen_a_secuencia(img, w=5, stride=2):
secuencia = []
altura = IMG_SIZE
for t in range(0, img.shape[1] - w + 1, stride):
ventana = img[:, t:t+w]
columna = np.mean(ventana, axis=1)
col_bin = (columna > 0.35).astype(int)
suma_tinta = np.sum(col_bin)
if suma_tinta == 0:
simbolo = 0
secuencia.append([simbolo])
continue
if suma_tinta < 8: tinta_q = 0
elif suma_tinta < 16: tinta_q = 1
elif suma_tinta < 24: tinta_q = 2
else: tinta_q = 3
cg = np.sum(col_bin * np.arange(altura)) / suma_tinta
if cg < 12: cg_q = 0
elif cg < 20: cg_q = 1
elif cg < 28: cg_q = 2
else: cg_q = 3
transiciones = np.sum(np.abs(np.diff(col_bin)))
trans_q = min(int(transiciones), 3)
indices_tinta = np.where(col_bin > 0)[0]
perfil_sup = indices_tinta[0]
perfil_inf = indices_tinta[-1]
up_q = 1 if perfil_sup > 10 else 0
low_q = 1 if perfil_inf < 30 else 0
simbolo = (tinta_q << 6) | (cg_q << 4) | (trans_q << 2) | (up_q << 1) | low_q
secuencia.append([simbolo])
return np.array(secuencia)Se calcularon características geométricas (distribución de tinta, centro de gravedad, número de transiciones y perfiles superior e inferior), cuantificadas en un conjunto discreto de 256 símbolos observables.
7.5.7 Construcción de los conjuntos de entrenamiento y prueba
Las secuencias obtenidas fueron organizadas en conjuntos balanceados de entrenamiento y evaluación:
def construir_dataset(dataset, max_muestras):
X, y = [], []
conteos = {k:0 for k in TARGETS_MAYUSCULAS}
for tensor, target in dataset:
if target not in TARGETS_MAYUSCULAS: continue
if conteos[target] >= max_muestras: continue
img = procesar_imagen(tensor)
seq = imagen_a_secuencia(img)
X.append(seq)
y.append(MAPEO_LETTERS[target])
conteos[target] += 1
if all(c >= max_muestras for c in conteos.values()): break
return X, np.array(y)
print("Construyendo entrenamiento...")
X_train, y_train = construir_dataset(emnist_train, TRAIN_POR_CLASE)
print("Construyendo prueba...")
X_test, y_test = construir_dataset(emnist_test, TEST_POR_CLASE)7.5.8 Entrenamiento de los modelos
Se entrenó un modelo HMM independiente para cada una de las letras del alfabeto:
modelos_hmm = {}
if os.path.exists(ARCHIVO_MODELOS_CAT):
print("\n[INFO] Modelos detectados en Drive. Cargando...")
with open(ARCHIVO_MODELOS_CAT, 'rb') as f:
modelos_hmm = pickle.load(f)
else:
print("\nEntrenando CategoricalHMMs...\n")
barra = tqdm(LETRAS_OBJETIVO, desc="Entrenando", unit="letra")
for letra in barra:
barra.set_description(f"Entrenando '{letra}'")
idx = np.where(y_train == letra)[0]
secuencias = [X_train[i] for i in idx]
lengths = [len(s) for s in secuencias]
X_concat = np.vstack(secuencias)
model = hmm.CategoricalHMM(
n_components=N_ESTADOS,
n_features=256,
n_iter=250,
random_state=42,
init_params='e'
)
model.startprob_ = np.zeros(N_ESTADOS)
model.startprob_[0] = 1.0
model.transmat_ = construir_matriz_bakis_flexible(N_ESTADOS)
model.fit(X_concat, lengths)
modelos_hmm[letra] = model
with open(ARCHIVO_MODELOS_CAT, 'wb') as f:
pickle.dump(modelos_hmm, f)7.5.9 Inferencia y clasificación
Cada muestra del conjunto de prueba fue evaluada frente a todos los HMM disponibles:
print("\nClasificando...\n")
y_pred = []
for seq in tqdm(X_test, desc="Evaluando Inferencia"):
scores = {}
for letra, model in modelos_hmm.items():
try: scores[letra] = model.score(seq)
except: scores[letra] = -np.inf
pred = max(scores, key=scores.get)
y_pred.append(pred)La predicción final se obtuvo seleccionando la letra cuyo modelo produjo la mayor puntuación de verosimilitud (principio de máxima verosimilitud).
7.5.10 Evaluación del desempeño
Finalmente, se calcularon métricas globales e individuales de rendimiento para evaluar la capacidad de generalización del sistema:
accuracy = accuracy_score(y_test, y_pred)
cm = confusion_matrix(y_test, y_pred, labels=LETRAS_OBJETIVO)
precision_por_letra = cm.diagonal() / cm.sum(axis=1)
print("\n" + "="*70)
print("EXACTITUD (ACCURACY) INDIVIDUAL POR LETRA")
print("="*70)
for i, letra in enumerate(LETRAS_OBJETIVO):
acc = precision_por_letra[i] * 100
print(f"Precisión Letra {letra} : {acc:>6.2f} %")
print("\n" + "="*70)
print(f"ACCURACY GLOBAL: {accuracy*100:.2f}%")
print("="*70 + "\n")
print(classification_report(y_test, y_pred, target_names=LETRAS_OBJETIVO))Asimismo, se construyó una matriz de confusión para identificar visualmente los patrones de error entre caracteres similares:
plt.figure(figsize=(16,14))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
xticklabels=LETRAS_OBJETIVO,
yticklabels=LETRAS_OBJETIVO)
plt.title(f'Matriz CategoricalHMM con Rasgos Estructurales (Precisión: {accuracy*100:.2f}%)', fontsize=16, fontweight='bold')
plt.ylabel('Letra Real', fontsize=14)
plt.xlabel('Letra Predicha', fontsize=14)
plt.tight_layout()
plt.show()