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 A

La 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 imagen

Este 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()