Handwritten Character Recognition - Training¶

Convolutional Neural Network (CNN) for EMNIST character classification.

In [1]:
import sys

if 'google.colab' in sys.modules:
    !git clone https://github.com/luidsonl/handwritten-character-recognition.git
    %cd handwritten-character-recognition/training
Cloning into 'handwritten-character-recognition'...
remote: Enumerating objects: 106, done.
remote: Counting objects: 100% (14/14), done.
remote: Compressing objects: 100% (9/9), done.
remote: Total 106 (delta 6), reused 10 (delta 5), pack-reused 92 (from 1)
Receiving objects: 100% (106/106), 92.69 MiB | 17.35 MiB/s, done.
Resolving deltas: 100% (41/41), done.
/content/handwritten-character-recognition/training

1. Setup¶

In [2]:
import os
os.environ['PYTHONHASHSEED'] = '42'
os.environ['TF_DETERMINISTIC_OPS'] = '1'
os.environ['TF_CUDNN_DETERMINISTIC'] = '1'
os.environ['CUBLAS_WORKSPACE_CONFIG'] = ':4096:8'

import sys
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.metrics import confusion_matrix

from config import (
    ARCHITECTURE, EPOCHS, BATCH_SIZE, VALIDATION_SPLIT,
    LOSS_FUNCTION, OPTIMIZER, LEARNING_RATE, METRICS, LABELS,
)
from libs import (
    set_seeds,
    load_and_preprocess,
    build_model,
    compile_model,
    train_model,
    evaluate_model,
    save_model_keras,
    print_evaluation_report,
    get_most_confused_pairs,
    get_label,
)

set_seeds()

2. Load and Explore Data¶

In [3]:
x_train, y_train, x_test, y_test = load_and_preprocess()

print(f"Training set: {x_train.shape}, {y_train.shape}")
print(f"Test set:     {x_test.shape}, {y_test.shape}")
print(f"Classes:      {y_train.shape[1]}")
Downloading EMNIST balanced dataset...
Download complete.
Extracting files...
Extraction complete.
Training set: (112800, 28, 28, 1), (112800, 47)
Test set:     (18800, 28, 28, 1), (18800, 47)
Classes:      47
In [4]:
_viz_rng = np.random.default_rng(42)

fig, axes = plt.subplots(2, 5, figsize=(12, 5))
for i, ax in enumerate(axes.flat):
    idx = _viz_rng.integers(len(x_train))
    ax.imshow(x_train[idx].squeeze(), cmap='gray')
    ax.set_title(get_label(np.argmax(y_train[idx])))
    ax.axis('off')
plt.tight_layout()
plt.show()

3. Build Model¶

In [5]:
model = build_model()
model = compile_model(model, learning_rate=LEARNING_RATE)
model.summary()
/usr/local/lib/python3.12/dist-packages/keras/src/layers/convolutional/base_conv.py:113: UserWarning: Do not pass an `input_shape`/`input_dim` argument to a layer. When using Sequential models, prefer using an `Input(shape)` object as the first layer in the model instead.
  super().__init__(activity_regularizer=activity_regularizer, **kwargs)
Model: "sequential"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                    ┃ Output Shape           ┃       Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ conv2d (Conv2D)                 │ (None, 28, 28, 32)     │           832 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ max_pooling2d (MaxPooling2D)    │ (None, 14, 14, 32)     │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ conv2d_1 (Conv2D)               │ (None, 14, 14, 48)     │        38,448 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ max_pooling2d_1 (MaxPooling2D)  │ (None, 7, 7, 48)       │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ conv2d_2 (Conv2D)               │ (None, 7, 7, 64)       │        76,864 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ flatten (Flatten)               │ (None, 3136)           │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense (Dense)                   │ (None, 512)            │     1,606,144 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_1 (Dense)                 │ (None, 84)             │        43,092 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_2 (Dense)                 │ (None, 47)             │         3,995 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
 Total params: 1,769,375 (6.75 MB)
 Trainable params: 1,769,375 (6.75 MB)
 Non-trainable params: 0 (0.00 B)

4. Train¶

In [6]:
history = train_model(
    model,
    x_train,
    y_train,
    epochs=EPOCHS,
    batch_size=BATCH_SIZE,
    validation_split=VALIDATION_SPLIT,
)
Epoch 1/15
794/794 ━━━━━━━━━━━━━━━━━━━━ 15s 12ms/step - accuracy: 0.7945 - loss: 0.6886 - val_accuracy: 0.8447 - val_loss: 0.4508
Epoch 2/15
794/794 ━━━━━━━━━━━━━━━━━━━━ 16s 12ms/step - accuracy: 0.8579 - loss: 0.4131 - val_accuracy: 0.8533 - val_loss: 0.4048
Epoch 3/15
794/794 ━━━━━━━━━━━━━━━━━━━━ 9s 12ms/step - accuracy: 0.8689 - loss: 0.3733 - val_accuracy: 0.8581 - val_loss: 0.4064
Epoch 4/15
794/794 ━━━━━━━━━━━━━━━━━━━━ 9s 12ms/step - accuracy: 0.8745 - loss: 0.3505 - val_accuracy: 0.8568 - val_loss: 0.3903
Epoch 5/15
794/794 ━━━━━━━━━━━━━━━━━━━━ 9s 11ms/step - accuracy: 0.8765 - loss: 0.3392 - val_accuracy: 0.8688 - val_loss: 0.3646
Epoch 6/15
794/794 ━━━━━━━━━━━━━━━━━━━━ 9s 12ms/step - accuracy: 0.8838 - loss: 0.3188 - val_accuracy: 0.8666 - val_loss: 0.3803
Epoch 7/15
794/794 ━━━━━━━━━━━━━━━━━━━━ 9s 12ms/step - accuracy: 0.8847 - loss: 0.3129 - val_accuracy: 0.8671 - val_loss: 0.3678
Epoch 8/15
794/794 ━━━━━━━━━━━━━━━━━━━━ 9s 12ms/step - accuracy: 0.8875 - loss: 0.3031 - val_accuracy: 0.8692 - val_loss: 0.3696
Epoch 9/15
794/794 ━━━━━━━━━━━━━━━━━━━━ 9s 12ms/step - accuracy: 0.8895 - loss: 0.2976 - val_accuracy: 0.8706 - val_loss: 0.3694
Epoch 10/15
794/794 ━━━━━━━━━━━━━━━━━━━━ 9s 12ms/step - accuracy: 0.8921 - loss: 0.2882 - val_accuracy: 0.8668 - val_loss: 0.3690
Epoch 11/15
794/794 ━━━━━━━━━━━━━━━━━━━━ 9s 12ms/step - accuracy: 0.8953 - loss: 0.2804 - val_accuracy: 0.8666 - val_loss: 0.3780
Epoch 12/15
794/794 ━━━━━━━━━━━━━━━━━━━━ 10s 12ms/step - accuracy: 0.8954 - loss: 0.2791 - val_accuracy: 0.8691 - val_loss: 0.3734
Epoch 13/15
794/794 ━━━━━━━━━━━━━━━━━━━━ 9s 12ms/step - accuracy: 0.8982 - loss: 0.2701 - val_accuracy: 0.8690 - val_loss: 0.3704
Epoch 14/15
794/794 ━━━━━━━━━━━━━━━━━━━━ 9s 11ms/step - accuracy: 0.8980 - loss: 0.2693 - val_accuracy: 0.8682 - val_loss: 0.3739
Epoch 14: early stopping
Restoring model weights from the end of the best epoch: 9.
In [7]:
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))

ax1.plot(history.history['accuracy'], label='Train')
ax1.plot(history.history['val_accuracy'], label='Validation')
ax1.set_title('Accuracy')
ax1.set_xlabel('Epoch')
ax1.legend()

ax2.plot(history.history['loss'], label='Train')
ax2.plot(history.history['val_loss'], label='Validation')
ax2.set_title('Loss')
ax2.set_xlabel('Epoch')
ax2.legend()

plt.tight_layout()
plt.show()

5. Evaluation¶

In [8]:
results = evaluate_model(model, x_test, y_test)
print_evaluation_report(results)
Accuracy:  0.8691
Precision: 0.8710
Recall:    0.8691

Most confused pairs (true -> predicted, count):
  'F' -> 'f': 167
  'L' -> '1': 152
  '0' -> 'O': 119
  'O' -> '0': 111
  'I' -> '1': 91
  'q' -> '9': 84
  'L' -> 'I': 83
  '9' -> 'q': 81
  'f' -> 'F': 78
  '1' -> 'I': 77
In [9]:
cm = results['confusion_matrix']

plt.figure(figsize=(14, 12))
sns.heatmap(cm, annot=False, fmt='d', cmap='Blues',
            xticklabels=LABELS, yticklabels=LABELS)
plt.xlabel('Predicted')
plt.ylabel('True')
plt.title('Confusion Matrix')
plt.tight_layout()
plt.show()

6. Confusion Analysis¶

In [10]:
confused = get_most_confused_pairs(cm, top_n=15)
print("Top 15 confused pairs (true -> predicted):")
print("-" * 40)
for true_label, pred_label, count in confused:
    print(f"  '{true_label}' -> '{pred_label}': {count}")
Top 15 confused pairs (true -> predicted):
----------------------------------------
  'F' -> 'f': 167
  'L' -> '1': 152
  '0' -> 'O': 119
  'O' -> '0': 111
  'I' -> '1': 91
  'q' -> '9': 84
  'L' -> 'I': 83
  '9' -> 'q': 81
  'f' -> 'F': 78
  '1' -> 'I': 77
  'g' -> 'q': 63
  '2' -> 'Z': 59
  'g' -> '9': 45
  '1' -> 'L': 40
  '5' -> 'S': 29

7. Export Model¶

In [11]:
import os

os.makedirs("models", exist_ok=True)
save_model_keras(model, "models/emnist_model.keras")
print("Model saved to models/emnist_model.keras")
Model saved to models/emnist_model.keras
In [12]:
import os
import base64
from IPython.display import display, HTML

if 'google.colab' in sys.modules:
    path = os.path.abspath("models/emnist_model.keras")
    with open(path, "rb") as f:
        data = base64.b64encode(f.read()).decode()
    filename = os.path.basename(path)
    display(HTML(f"""
    <button onclick="document.getElementById('dl-keras').click()"
            style="padding:8px 16px;background:#4CAF50;color:white;border:none;border-radius:4px;cursor:pointer;font-size:14px;">
      Baixar {filename}
    </button>
    <a id="dl-keras" href="data:application/octet-stream;base64,{data}" download="{filename}" style="display:none"></a>
    """))

8. Export TF.js Model¶

In [13]:
from libs.model import convert_to_tfjs
import os
import base64
from IPython.display import display, HTML

os.makedirs("web_model", exist_ok=True)
convert_to_tfjs(model, "web_model")

for f in sorted(os.listdir("web_model")):
    size = os.path.getsize(os.path.join("web_model", f))
    print(f"  {f}: {size:,} bytes")

if 'google.colab' in sys.modules:
    buttons_html = ''
    for f in sorted(os.listdir("web_model")):
        path = os.path.abspath(os.path.join("web_model", f))
        with open(path, "rb") as fh:
            data = base64.b64encode(fh.read()).decode()
        btn_id = f"dl-{f.replace('.', '-')}"
        color = '#2196F3' if f.endswith('.json') else '#FF9800'
        buttons_html += f"""
        <button onclick="document.getElementById('{btn_id}').click()"
                style="padding:8px 16px;background:{color};color:white;border:none;border-radius:4px;cursor:pointer;font-size:14px;margin-right:8px;">
          Baixar {f}
        </button>
        <a id="{btn_id}" href="data:application/octet-stream;base64,{data}" download="{f}" style="display:none"></a>
        """
    display(HTML(buttons_html))
  group1-shard1of1.bin: 7,077,500 bytes
  model.json: 5,327 bytes