| """
|
| train.py
|
| ========
|
| Training script for the Kurdish handwritten word recognition models in the
|
| Karez/KHWR repository. Supports four model families through a single
|
| --model_type flag:
|
|
|
| baseline -- CRNN without attention
|
| luong -- CRNN with Luong multiplicative attention
|
| mhsa -- CRNN with Multi-Head Self-Attention
|
| faa -- CRNN with Frequency-Adaptive Attention (proposed)
|
|
|
| Example:
|
| python Scripts/train.py \
|
| --model_type faa \
|
| --data_dir ./data/DASTNUS/Unique-Words \
|
| --vocab_path FAA-Word-Model/vocab.json \
|
| --output_dir ./output/faa_seed42 \
|
| --seed 42
|
| """
|
|
|
| import argparse
|
| import os
|
| import sys
|
| import json
|
| import glob
|
| import math
|
| import random
|
| import numpy as np
|
| import torch
|
| import torch.nn as nn
|
| import torch.nn.functional as F
|
| import torch.utils.data as data
|
| import torchvision.transforms as T
|
| from PIL import Image
|
| from collections import Counter
|
| from datetime import datetime
|
| from tqdm import tqdm
|
|
|
|
|
|
|
|
|
|
|
| def parse_args():
|
| parser = argparse.ArgumentParser(
|
| description="Kurdish Handwritten Word Recognition Training"
|
| )
|
|
|
| parser.add_argument("--model_type", type=str, required=True,
|
| choices=["baseline", "luong", "mhsa", "faa"],
|
| help="Which model family to train")
|
|
|
| parser.add_argument("--data_dir", type=str, required=True,
|
| help="Root directory containing Training/, "
|
| "Validation/, and Testing/ subfolders")
|
| parser.add_argument("--vocab_path", type=str, required=True,
|
| help="Path to vocabulary JSON file (vocab.json)")
|
|
|
| parser.add_argument("--img_height", type=int, default=64)
|
| parser.add_argument("--img_width", type=int, default=164)
|
|
|
| parser.add_argument("--batch_size", type=int, default=32)
|
| parser.add_argument("--num_epochs", type=int, default=80)
|
| parser.add_argument("--learning_rate", type=float, default=5e-4)
|
| parser.add_argument("--grad_clip", type=float, default=5.0)
|
| parser.add_argument("--weight_decay", type=float, default=1e-4)
|
| parser.add_argument("--seed", type=int, default=42)
|
|
|
| parser.add_argument("--hidden_size", type=int, default=160,
|
| help="LSTM hidden size per direction")
|
| parser.add_argument("--lstm_layers", type=int, default=3)
|
| parser.add_argument("--lstm_dropout", type=float, default=0.3)
|
| parser.add_argument("--cnn_dropout", type=float, default=0.2)
|
|
|
| parser.add_argument("--num_heads", type=int, default=4)
|
| parser.add_argument("--ff_dim", type=int, default=320)
|
| parser.add_argument("--mhsa_dropout", type=float, default=0.1)
|
|
|
| parser.add_argument("--patience", type=int, default=10)
|
|
|
| parser.add_argument("--no_aug", action="store_true",
|
| help="Disable adaptive augmentation")
|
|
|
| parser.add_argument("--init_checkpoint", type=str, default=None,
|
| help="Optional .pth checkpoint to initialize from "
|
| "(e.g. for few-shot fine-tuning)")
|
|
|
| parser.add_argument("--output_dir", type=str, default="./output",
|
| help="Directory to save best model and logs")
|
| return parser.parse_args()
|
|
|
|
|
|
|
|
|
|
|
| def load_vocabulary(vocab_path):
|
| """Load vocab.json; expects char-to-index mapping with <BLANK> at 0."""
|
| with open(vocab_path, "r", encoding="utf-8") as f:
|
| vocab = json.load(f)
|
| if "<BLANK>" in vocab:
|
| blank_idx = vocab["<BLANK>"]
|
| else:
|
| blank_idx = 0
|
| idx_to_char = {v: k for k, v in vocab.items()}
|
| return vocab, idx_to_char, blank_idx
|
|
|
|
|
| def indices_to_text(indices, idx_to_char, blank_idx=0):
|
| """Map a list of integer indices back to a string (skips blank)."""
|
| return "".join(idx_to_char.get(int(i), "")
|
| for i in indices if int(i) != blank_idx)
|
|
|
|
|
| def text_to_indices(text, vocab):
|
| return [vocab[c] for c in text if c in vocab]
|
|
|
|
|
|
|
|
|
|
|
| class KurdishWordDataset(data.Dataset):
|
| """
|
| Loads (.tif, .txt) image-label pairs from a folder. Each .tif has a
|
| matching .txt with one line of Kurdish ground truth.
|
| """
|
| def __init__(self, root_dir, img_height, img_width, augment=False,
|
| aug_strength=0.0):
|
| self.root_dir = root_dir
|
| self.img_height = img_height
|
| self.img_width = img_width
|
| self.augment = augment
|
| self.aug_strength = aug_strength
|
|
|
| self.image_files = sorted(glob.glob(os.path.join(root_dir, "*.tif")))
|
|
|
| self.image_files = [
|
| p for p in self.image_files
|
| if os.path.exists(os.path.splitext(p)[0] + ".txt")
|
| ]
|
| print(f" Loaded {len(self.image_files)} samples from {root_dir}")
|
|
|
| def __len__(self):
|
| return len(self.image_files)
|
|
|
| def set_aug_strength(self, strength):
|
| """Updated each epoch by the trainer (adaptive augmentation)."""
|
| self.aug_strength = max(0.0, min(1.0, strength))
|
|
|
| def __getitem__(self, idx):
|
| img_path = self.image_files[idx]
|
| label_path = os.path.splitext(img_path)[0] + ".txt"
|
|
|
|
|
| image = Image.open(img_path).convert("L")
|
| ow, oh = image.size
|
| new_h = self.img_height
|
| new_w = min(int(new_h * ow / oh), self.img_width)
|
| image = image.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
| canvas = Image.new("L", (self.img_width, self.img_height), color=255)
|
| canvas.paste(image, (0, 0))
|
|
|
|
|
| if self.augment and self.aug_strength > 0.0:
|
| canvas = self._apply_augmentation(canvas)
|
|
|
|
|
| canvas = T.functional.to_tensor(canvas)
|
| canvas = T.functional.normalize(canvas, (0.5,), (0.5,))
|
|
|
|
|
| try:
|
| with open(label_path, "r", encoding="utf-8") as f:
|
| text = f.readline().strip()
|
| except UnicodeDecodeError:
|
| with open(label_path, "r", encoding="utf-8-sig") as f:
|
| text = f.readline().strip()
|
|
|
| return canvas, text
|
|
|
| def _apply_augmentation(self, img):
|
| """Light geometric and intensity perturbations scaled by aug_strength."""
|
| s = self.aug_strength
|
|
|
| if random.random() < 0.5:
|
| angle = random.uniform(-3.0 * s, 3.0 * s)
|
| img = img.rotate(angle, resample=Image.BILINEAR, fillcolor=255)
|
|
|
| if random.random() < 0.5:
|
| dx = int(random.uniform(-4 * s, 4 * s))
|
| dy = int(random.uniform(-2 * s, 2 * s))
|
| img = img.transform(img.size, Image.AFFINE,
|
| (1, 0, dx, 0, 1, dy), fillcolor=255)
|
|
|
| if random.random() < 0.3:
|
| arr = np.asarray(img, dtype=np.float32)
|
| arr += np.random.normal(0, 5.0 * s, arr.shape)
|
| arr = np.clip(arr, 0, 255).astype(np.uint8)
|
| img = Image.fromarray(arr, mode="L")
|
| return img
|
|
|
|
|
| def collate_fn(batch, vocab):
|
| imgs, texts = zip(*batch)
|
| imgs = torch.stack(imgs, 0)
|
| target_indices = []
|
| target_lengths = []
|
| for t in texts:
|
| idx_seq = text_to_indices(t, vocab)
|
| target_indices.extend(idx_seq)
|
| target_lengths.append(len(idx_seq))
|
| targets = torch.tensor(target_indices, dtype=torch.long)
|
| lengths = torch.tensor(target_lengths, dtype=torch.long)
|
| return imgs, targets, lengths, list(texts)
|
|
|
|
|
|
|
|
|
|
|
| class BidirectionalLSTM(nn.Module):
|
| def __init__(self, nIn, nHidden, nOut, dropout=0.0):
|
| super().__init__()
|
| self.rnn = nn.LSTM(nIn, nHidden, bidirectional=True)
|
| self.embedding = nn.Linear(nHidden * 2, nOut)
|
| self.dropout = nn.Dropout(dropout) if dropout > 0 else None
|
|
|
| def forward(self, x):
|
| recurrent, _ = self.rnn(x)
|
| if self.dropout:
|
| recurrent = self.dropout(recurrent)
|
| T, b, h = recurrent.size()
|
| return self.embedding(recurrent.view(T * b, h)).view(T, b, -1)
|
|
|
|
|
| def _build_cnn(nc, cnn_dropout):
|
| """Shared 6-block CNN backbone (max 256 channels)."""
|
| return nn.Sequential(
|
| nn.Conv2d(nc, 64, 3, 1, 1), nn.BatchNorm2d(64), nn.ReLU(True),
|
| nn.MaxPool2d(2, 2), nn.Dropout2d(cnn_dropout),
|
|
|
| nn.Conv2d(64, 128, 3, 1, 1), nn.BatchNorm2d(128), nn.ReLU(True),
|
| nn.MaxPool2d(2, 2), nn.Dropout2d(cnn_dropout),
|
|
|
| nn.Conv2d(128, 256, 3, 1, 1), nn.BatchNorm2d(256), nn.ReLU(True),
|
| nn.MaxPool2d((2, 1), (2, 1)), nn.Dropout2d(cnn_dropout),
|
|
|
| nn.Conv2d(256, 256, 3, 1, 1), nn.BatchNorm2d(256), nn.ReLU(True),
|
| nn.MaxPool2d((2, 1), (2, 1)), nn.Dropout2d(cnn_dropout),
|
|
|
| nn.Conv2d(256, 256, 3, 1, 1), nn.BatchNorm2d(256), nn.ReLU(True),
|
| nn.MaxPool2d((2, 1), (2, 1)), nn.Dropout2d(cnn_dropout),
|
|
|
| nn.Conv2d(256, 256, 3, 1, 1), nn.BatchNorm2d(256), nn.ReLU(True),
|
| nn.MaxPool2d((2, 1), (2, 1)), nn.Dropout2d(cnn_dropout),
|
|
|
| nn.Conv2d(256, 256, (1, 3), 1, (0, 1)), nn.BatchNorm2d(256), nn.ReLU(True),
|
| )
|
|
|
|
|
| class BaselineCRNN(nn.Module):
|
| """CRNN baseline without attention."""
|
| def __init__(self, nclass, nh, num_lstm_layers, lstm_dropout, cnn_dropout):
|
| super().__init__()
|
| self.cnn = _build_cnn(1, cnn_dropout)
|
| layers = []
|
| input_size = 256
|
| for i in range(num_lstm_layers):
|
| out_size = nclass if i == num_lstm_layers - 1 else nh
|
| drop = 0 if i == num_lstm_layers - 1 else lstm_dropout
|
| layers.append(BidirectionalLSTM(input_size, nh, out_size, dropout=drop))
|
| input_size = nh
|
| self.rnn = nn.Sequential(*layers)
|
|
|
| def forward(self, x):
|
| conv = self.cnn(x)
|
| b, c, h, w = conv.size()
|
| if h != 1:
|
| conv = F.adaptive_avg_pool2d(conv, (1, w))
|
| conv = conv.squeeze(2).permute(2, 0, 1)
|
| return self.rnn(conv)
|
|
|
|
|
| class LuongAttention(nn.Module):
|
| def __init__(self, hidden_size):
|
| super().__init__()
|
| self.W_a = nn.Linear(hidden_size, hidden_size, bias=False)
|
| self.out_proj = nn.Linear(hidden_size * 2, hidden_size)
|
| self.norm = nn.LayerNorm(hidden_size)
|
|
|
| def forward(self, x):
|
| T, B, H = x.size()
|
| keys = self.W_a(x)
|
| x_bth = x.permute(1, 0, 2)
|
| keys_bth = keys.permute(1, 0, 2)
|
| scores = torch.bmm(x_bth, keys_bth.transpose(1, 2)) / (H ** 0.5)
|
| weights = torch.softmax(scores, dim=-1)
|
| context = torch.bmm(weights, x_bth).permute(1, 0, 2)
|
| combined = torch.cat([x, context], dim=-1)
|
| output = torch.tanh(self.out_proj(combined))
|
| return self.norm(output + x)
|
|
|
|
|
| class LuongCRNN(nn.Module):
|
| def __init__(self, nclass, nh, num_lstm_layers, lstm_dropout, cnn_dropout):
|
| super().__init__()
|
| self.cnn = _build_cnn(1, cnn_dropout)
|
| self.lstm_layers = nn.ModuleList()
|
| input_size = 256
|
| for i in range(num_lstm_layers):
|
| out_size = nclass if i == num_lstm_layers - 1 else nh
|
| drop = 0 if i == num_lstm_layers - 1 else lstm_dropout
|
| self.lstm_layers.append(
|
| BidirectionalLSTM(input_size, nh, out_size, dropout=drop)
|
| )
|
| input_size = nh
|
| self.attention = LuongAttention(hidden_size=nh)
|
|
|
| def forward(self, x):
|
| conv = self.cnn(x)
|
| b, c, h, w = conv.size()
|
| if h != 1:
|
| conv = F.adaptive_avg_pool2d(conv, (1, w))
|
| out = conv.squeeze(2).permute(2, 0, 1)
|
| for i, layer in enumerate(self.lstm_layers):
|
| out = layer(out)
|
| if i == 1:
|
| out = self.attention(out)
|
| return out
|
|
|
|
|
| class MultiHeadSelfAttention(nn.Module):
|
| def __init__(self, hidden_size, num_heads, ff_dim, dropout):
|
| super().__init__()
|
| assert hidden_size % num_heads == 0
|
| self.hidden_size = hidden_size
|
| self.num_heads = num_heads
|
| self.head_dim = hidden_size // num_heads
|
| self.q_proj = nn.Linear(hidden_size, hidden_size)
|
| self.k_proj = nn.Linear(hidden_size, hidden_size)
|
| self.v_proj = nn.Linear(hidden_size, hidden_size)
|
| self.out_proj = nn.Linear(hidden_size, hidden_size)
|
| self.ff = nn.Sequential(
|
| nn.Linear(hidden_size, ff_dim),
|
| nn.ReLU(inplace=True),
|
| nn.Dropout(dropout),
|
| nn.Linear(ff_dim, hidden_size),
|
| )
|
| self.norm1 = nn.LayerNorm(hidden_size)
|
| self.norm2 = nn.LayerNorm(hidden_size)
|
| self.dropout = nn.Dropout(dropout)
|
|
|
| def forward(self, x):
|
| T, B, H = x.size()
|
| x_btn = x.permute(1, 0, 2)
|
| def split_heads(t):
|
| return t.view(B, T, self.num_heads, self.head_dim).transpose(1, 2)
|
| Q = split_heads(self.q_proj(x_btn))
|
| K = split_heads(self.k_proj(x_btn))
|
| V = split_heads(self.v_proj(x_btn))
|
| scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.head_dim)
|
| weights = self.dropout(torch.softmax(scores, dim=-1))
|
| attn_out = torch.matmul(weights, V).transpose(1, 2).contiguous()
|
| attn_out = self.out_proj(attn_out.view(B, T, H))
|
| x_btn = self.norm1(x_btn + self.dropout(attn_out))
|
| ff_out = self.ff(x_btn)
|
| x_btn = self.norm2(x_btn + self.dropout(ff_out))
|
| return x_btn.permute(1, 0, 2)
|
|
|
|
|
| class MHSACRNN(nn.Module):
|
| def __init__(self, nclass, nh, num_lstm_layers, lstm_dropout, cnn_dropout,
|
| num_heads, ff_dim, mhsa_dropout):
|
| super().__init__()
|
| self.cnn = _build_cnn(1, cnn_dropout)
|
| self.lstm_layers = nn.ModuleList()
|
| input_size = 256
|
| for i in range(num_lstm_layers):
|
| out_size = nclass if i == num_lstm_layers - 1 else nh
|
| drop = 0 if i == num_lstm_layers - 1 else lstm_dropout
|
| self.lstm_layers.append(
|
| BidirectionalLSTM(input_size, nh, out_size, dropout=drop)
|
| )
|
| input_size = nh
|
| self.attention = MultiHeadSelfAttention(
|
| nh, num_heads=num_heads, ff_dim=ff_dim, dropout=mhsa_dropout
|
| )
|
|
|
| def forward(self, x):
|
| conv = self.cnn(x)
|
| b, c, h, w = conv.size()
|
| if h != 1:
|
| conv = F.adaptive_avg_pool2d(conv, (1, w))
|
| out = conv.squeeze(2).permute(2, 0, 1)
|
| for i, layer in enumerate(self.lstm_layers):
|
| out = layer(out)
|
| if i == 1:
|
| out = self.attention(out)
|
| return out
|
|
|
|
|
| class FrequencyAdaptiveAttention(nn.Module):
|
| """
|
| Proposed Frequency-Adaptive Attention. freq_weights is a non-trainable
|
| buffer; surrounding modules learn how to use it.
|
| """
|
| def __init__(self, hidden_size, vocab_size, freq_weights):
|
| super().__init__()
|
| self.hidden_size = hidden_size
|
| self.register_buffer("freq_weights", freq_weights)
|
| self.attention = nn.Sequential(
|
| nn.Linear(hidden_size, hidden_size // 2),
|
| nn.Tanh(),
|
| nn.Linear(hidden_size // 2, 1),
|
| )
|
| self.char_predictor = nn.Linear(hidden_size, vocab_size)
|
| self.freq_adapter = nn.Sequential(
|
| nn.Linear(hidden_size + 1, hidden_size // 2),
|
| nn.ReLU(),
|
| nn.Linear(hidden_size // 2, hidden_size),
|
| nn.Sigmoid(),
|
| )
|
| self.out_proj = nn.Linear(hidden_size * 2, hidden_size)
|
| self.gate = nn.Sequential(
|
| nn.Linear(hidden_size * 2, hidden_size),
|
| nn.Sigmoid(),
|
| )
|
|
|
| def forward(self, x):
|
| T, batch, _ = x.size()
|
| attn_scores = self.attention(x)
|
| attn_weights = torch.softmax(attn_scores, dim=0)
|
| char_logits = self.char_predictor(x)
|
| char_probs = torch.softmax(char_logits, dim=-1)
|
| expected_rarity = (
|
| char_probs * self.freq_weights.unsqueeze(0).unsqueeze(0)
|
| ).sum(dim=-1, keepdim=True)
|
| attn_boosted = attn_weights * (1.0 + expected_rarity)
|
| attn_boosted = attn_boosted / (attn_boosted.sum(dim=0, keepdim=True) + 1e-8)
|
| context = (x * attn_boosted).sum(dim=0, keepdim=True).expand(T, -1, -1)
|
| freq_input = torch.cat([x, expected_rarity], dim=-1)
|
| freq_adapt_gate = self.freq_adapter(freq_input)
|
| combined = torch.cat([x, context], dim=-1)
|
| projected = self.out_proj(combined)
|
| gate = self.gate(combined)
|
| return gate * (freq_adapt_gate * projected) + (1 - gate) * x
|
|
|
|
|
| class FAACRNN(nn.Module):
|
| def __init__(self, nclass, nh, num_lstm_layers, lstm_dropout, cnn_dropout,
|
| freq_weights):
|
| super().__init__()
|
| self.cnn = _build_cnn(1, cnn_dropout)
|
| self.lstm_layers = nn.ModuleList()
|
| input_size = 256
|
| for i in range(num_lstm_layers):
|
| out_size = nclass if i == num_lstm_layers - 1 else nh
|
| drop = 0 if i == num_lstm_layers - 1 else lstm_dropout
|
| self.lstm_layers.append(
|
| BidirectionalLSTM(input_size, nh, out_size, dropout=drop)
|
| )
|
| input_size = nh
|
| self.freq_attention = FrequencyAdaptiveAttention(
|
| hidden_size=nh, vocab_size=nclass, freq_weights=freq_weights
|
| )
|
|
|
| def forward(self, x):
|
| conv = self.cnn(x)
|
| b, c, h, w = conv.size()
|
| if h != 1:
|
| conv = F.adaptive_avg_pool2d(conv, (1, w))
|
| out = conv.squeeze(2).permute(2, 0, 1)
|
| for i, layer in enumerate(self.lstm_layers):
|
| out = layer(out)
|
| if i == 1:
|
| out = self.freq_attention(out)
|
| return out
|
|
|
|
|
| def build_model(args, vocab_size, freq_weights, device):
|
| """Instantiate the architecture selected by --model_type."""
|
| if args.model_type == "baseline":
|
| return BaselineCRNN(
|
| nclass=vocab_size, nh=args.hidden_size,
|
| num_lstm_layers=args.lstm_layers,
|
| lstm_dropout=args.lstm_dropout, cnn_dropout=args.cnn_dropout
|
| ).to(device)
|
| if args.model_type == "luong":
|
| return LuongCRNN(
|
| nclass=vocab_size, nh=args.hidden_size,
|
| num_lstm_layers=args.lstm_layers,
|
| lstm_dropout=args.lstm_dropout, cnn_dropout=args.cnn_dropout
|
| ).to(device)
|
| if args.model_type == "mhsa":
|
| return MHSACRNN(
|
| nclass=vocab_size, nh=args.hidden_size,
|
| num_lstm_layers=args.lstm_layers,
|
| lstm_dropout=args.lstm_dropout, cnn_dropout=args.cnn_dropout,
|
| num_heads=args.num_heads, ff_dim=args.ff_dim,
|
| mhsa_dropout=args.mhsa_dropout
|
| ).to(device)
|
| if args.model_type == "faa":
|
| return FAACRNN(
|
| nclass=vocab_size, nh=args.hidden_size,
|
| num_lstm_layers=args.lstm_layers,
|
| lstm_dropout=args.lstm_dropout, cnn_dropout=args.cnn_dropout,
|
| freq_weights=freq_weights.to(device)
|
| ).to(device)
|
| raise ValueError(f"Unknown model_type: {args.model_type}")
|
|
|
|
|
|
|
|
|
|
|
| def compute_freq_weights(train_dir, vocab):
|
| """
|
| Compute per-character rarity weights from the training labels:
|
| weight[i] = log(N / count_i), normalized to [0, 1]
|
| The CTC blank (index 0) is assigned weight 0.
|
| """
|
| counts = Counter()
|
| for label_path in glob.glob(os.path.join(train_dir, "*.txt")):
|
| try:
|
| with open(label_path, "r", encoding="utf-8") as f:
|
| text = f.readline().strip()
|
| except UnicodeDecodeError:
|
| with open(label_path, "r", encoding="utf-8-sig") as f:
|
| text = f.readline().strip()
|
| counts.update(text)
|
|
|
| vocab_size = len(vocab)
|
| weights = torch.zeros(vocab_size, dtype=torch.float32)
|
| total = sum(counts.values()) + len(vocab)
|
| for char, idx in vocab.items():
|
| if char == "<BLANK>":
|
| continue
|
| c = counts.get(char, 0) + 1
|
| weights[idx] = math.log(total / c)
|
|
|
| if weights.max() > 0:
|
| weights = weights / weights.max()
|
| return weights
|
|
|
|
|
|
|
|
|
|
|
| def ctc_greedy_decode(logits, blank_idx=0):
|
| _, max_idx = torch.max(logits, dim=2)
|
| decoded = []
|
| for b in range(max_idx.size(1)):
|
| seq = max_idx[:, b].cpu().numpy()
|
| out, prev = [], None
|
| for idx in seq:
|
| if idx != blank_idx and idx != prev:
|
| out.append(int(idx))
|
| prev = idx
|
| decoded.append(out)
|
| return decoded
|
|
|
|
|
| def edit_distance(s1, s2):
|
| if len(s1) < len(s2):
|
| return edit_distance(s2, s1)
|
| if not s2:
|
| return len(s1)
|
| prev = range(len(s2) + 1)
|
| for i, c1 in enumerate(s1):
|
| curr = [i + 1]
|
| for j, c2 in enumerate(s2):
|
| curr.append(min(prev[j+1]+1, curr[j]+1, prev[j]+(c1 != c2)))
|
| prev = curr
|
| return prev[-1]
|
|
|
|
|
| def compute_cer(preds, gts):
|
| d = sum(edit_distance(p, t) for p, t in zip(preds, gts))
|
| c = sum(len(t) for t in gts)
|
| return d / max(c, 1)
|
|
|
|
|
| def compute_wer(preds, gts):
|
| if not preds:
|
| return 0.0
|
| return sum(1 for p, t in zip(preds, gts) if p != t) / len(preds)
|
|
|
|
|
|
|
|
|
|
|
| def train_one_epoch(model, loader, optimizer, scheduler, ctc_loss,
|
| device, grad_clip, scaler):
|
| model.train()
|
| running_loss = 0.0
|
| n_batches = 0
|
| use_amp = (device.type == "cuda")
|
|
|
| for imgs, targets, lengths, _ in tqdm(loader, desc=" Train", leave=False):
|
| imgs = imgs.to(device)
|
| targets = targets.to(device)
|
| lengths = lengths.to(device)
|
|
|
| optimizer.zero_grad()
|
| with torch.cuda.amp.autocast(enabled=use_amp):
|
| logits = model(imgs)
|
| log_probs = F.log_softmax(logits, dim=2)
|
| T, B, _ = log_probs.size()
|
| input_lengths = torch.full((B,), T, dtype=torch.long, device=device)
|
| loss = ctc_loss(log_probs, targets, input_lengths, lengths)
|
|
|
| if use_amp:
|
| scaler.scale(loss).backward()
|
| scaler.unscale_(optimizer)
|
| torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip)
|
| scaler.step(optimizer)
|
| scaler.update()
|
| else:
|
| loss.backward()
|
| torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip)
|
| optimizer.step()
|
|
|
| if scheduler is not None:
|
| scheduler.step()
|
|
|
| running_loss += loss.item()
|
| n_batches += 1
|
|
|
| return running_loss / max(n_batches, 1)
|
|
|
|
|
| @torch.no_grad()
|
| def validate(model, loader, idx_to_char, device, blank_idx=0):
|
| model.eval()
|
| preds_all, gts_all = [], []
|
| for imgs, _, _, texts in tqdm(loader, desc=" Val", leave=False):
|
| imgs = imgs.to(device)
|
| out = model(imgs)
|
| decoded = ctc_greedy_decode(out, blank_idx=blank_idx)
|
| for seq in decoded:
|
| preds_all.append(indices_to_text(seq, idx_to_char, blank_idx))
|
| gts_all.extend(texts)
|
| return compute_cer(preds_all, gts_all), compute_wer(preds_all, gts_all)
|
|
|
|
|
|
|
|
|
|
|
| def main():
|
| args = parse_args()
|
|
|
|
|
| torch.manual_seed(args.seed)
|
| np.random.seed(args.seed)
|
| random.seed(args.seed)
|
| if torch.cuda.is_available():
|
| torch.cuda.manual_seed_all(args.seed)
|
|
|
| os.makedirs(args.output_dir, exist_ok=True)
|
| log_path = os.path.join(args.output_dir, "training_log.txt")
|
| log_file = open(log_path, "w", encoding="utf-8")
|
|
|
| def log(msg=""):
|
| print(msg)
|
| log_file.write(msg + "\n")
|
| log_file.flush()
|
|
|
| log("=" * 70)
|
| log(f"KHWR Training | model_type = {args.model_type}")
|
| log("=" * 70)
|
| log(f"Timestamp : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
| log(f"Data dir : {args.data_dir}")
|
| log(f"Vocab : {args.vocab_path}")
|
| log(f"Output : {args.output_dir}")
|
| log(f"Seed : {args.seed}")
|
|
|
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| log(f"Device : {device}")
|
| if device.type == "cuda":
|
| log(f"GPU : {torch.cuda.get_device_name(0)}")
|
|
|
|
|
| vocab, idx_to_char, blank_idx = load_vocabulary(args.vocab_path)
|
| vocab_size = len(vocab)
|
| log(f"Vocab size: {vocab_size} (blank index = {blank_idx})")
|
|
|
|
|
| train_dir = os.path.join(args.data_dir, "Training")
|
| val_dir = os.path.join(args.data_dir, "Validation")
|
| test_dir = os.path.join(args.data_dir, "Testing")
|
| freq_weights = compute_freq_weights(train_dir, vocab)
|
|
|
|
|
| train_ds = KurdishWordDataset(train_dir, args.img_height, args.img_width,
|
| augment=not args.no_aug, aug_strength=0.3)
|
| val_ds = KurdishWordDataset(val_dir, args.img_height, args.img_width,
|
| augment=False)
|
| test_ds = KurdishWordDataset(test_dir, args.img_height, args.img_width,
|
| augment=False)
|
|
|
| def make_loader(ds, shuffle):
|
| return data.DataLoader(
|
| ds, batch_size=args.batch_size, shuffle=shuffle,
|
| num_workers=0, pin_memory=True,
|
| collate_fn=lambda b: collate_fn(b, vocab)
|
| )
|
|
|
| train_loader = make_loader(train_ds, shuffle=True)
|
| val_loader = make_loader(val_ds, shuffle=False)
|
| test_loader = make_loader(test_ds, shuffle=False)
|
|
|
|
|
| model = build_model(args, vocab_size, freq_weights, device)
|
| total_params = sum(p.numel() for p in model.parameters())
|
| log(f"Parameters: {total_params:,}")
|
|
|
|
|
| if args.init_checkpoint is not None and os.path.exists(args.init_checkpoint):
|
| ckpt = torch.load(args.init_checkpoint, map_location=device)
|
| sd = ckpt.get("model_state_dict", ckpt)
|
| missing, unexpected = model.load_state_dict(sd, strict=False)
|
| log(f"Loaded init checkpoint: {args.init_checkpoint}")
|
| if missing: log(f" Missing keys : {len(missing)}")
|
| if unexpected: log(f" Unexpected keys: {len(unexpected)}")
|
|
|
|
|
| optimizer = torch.optim.AdamW(model.parameters(),
|
| lr=args.learning_rate,
|
| weight_decay=args.weight_decay)
|
| steps_per_epoch = max(1, len(train_loader))
|
| scheduler = torch.optim.lr_scheduler.OneCycleLR(
|
| optimizer, max_lr=args.learning_rate,
|
| total_steps=args.num_epochs * steps_per_epoch
|
| )
|
| ctc_loss = nn.CTCLoss(blank=blank_idx, reduction="mean", zero_infinity=True)
|
| scaler = torch.cuda.amp.GradScaler(enabled=(device.type == "cuda"))
|
|
|
|
|
| best_val_cer = float("inf")
|
| best_epoch = 0
|
| patience_ctr = 0
|
|
|
| for epoch in range(1, args.num_epochs + 1):
|
|
|
| if not args.no_aug:
|
| train_ds.set_aug_strength(min(1.0, 0.3 + 0.7 * (epoch - 1) / max(1, args.num_epochs - 1)))
|
|
|
| loss = train_one_epoch(model, train_loader, optimizer, scheduler,
|
| ctc_loss, device, args.grad_clip, scaler)
|
| val_cer, val_wer = validate(model, val_loader, idx_to_char,
|
| device, blank_idx)
|
|
|
| improved = val_cer < best_val_cer
|
| if improved:
|
| best_val_cer = val_cer
|
| best_epoch = epoch
|
| patience_ctr = 0
|
| ckpt_path = os.path.join(args.output_dir, "best_model.pth")
|
| torch.save({
|
| "epoch": epoch,
|
| "model_state_dict": model.state_dict(),
|
| "val_cer": val_cer,
|
| "val_wer": val_wer,
|
| "model_config": {
|
| "model_type": args.model_type,
|
| "vocab_size": vocab_size,
|
| "lstm_hidden_size": args.hidden_size,
|
| "lstm_layers": args.lstm_layers,
|
| "lstm_dropout": args.lstm_dropout,
|
| "cnn_dropout": args.cnn_dropout,
|
| },
|
| }, ckpt_path)
|
| else:
|
| patience_ctr += 1
|
|
|
| marker = " *" if improved else ""
|
| log(f"Epoch {epoch:>3}/{args.num_epochs} "
|
| f"train_loss={loss:.4f} val_CER={val_cer:.4f} "
|
| f"val_WER={val_wer:.4f}{marker}")
|
|
|
| if patience_ctr >= args.patience:
|
| log(f"Early stopping at epoch {epoch} "
|
| f"(patience={args.patience}, best={best_val_cer:.4f} @ epoch {best_epoch})")
|
| break
|
|
|
|
|
| ckpt_path = os.path.join(args.output_dir, "best_model.pth")
|
| if os.path.exists(ckpt_path):
|
| ckpt = torch.load(ckpt_path, map_location=device)
|
| model.load_state_dict(ckpt["model_state_dict"])
|
|
|
| test_cer, test_wer = validate(model, test_loader, idx_to_char,
|
| device, blank_idx)
|
| log("")
|
| log("=" * 70)
|
| log(f"FINAL TEST RESULTS (best epoch = {best_epoch})")
|
| log("=" * 70)
|
| log(f"Test CER : {test_cer:.4f}")
|
| log(f"Test WER : {test_wer:.4f}")
|
| log(f"Best Val : {best_val_cer:.4f}")
|
| log(f"Checkpoint: {ckpt_path}")
|
|
|
| log_file.close()
|
|
|
|
|
| if __name__ == "__main__":
|
| main() |