File size: 15,805 Bytes
5ce9e81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
"""
================================================================================
Tiny-Aya-Global Urdu -> Roman Urdu : Sequential Fine-Tuning Script
v2 (HF, merged) + HBL dataset -> v4 (HF, merged)
================================================================================

Fully offline. No bitsandbytes. No 4-bit quantization. FP16 + LoRA/PEFT.

Reused from the successful Kaggle v2 run (tiny-aya-ft-v2.ipynb):
  - LoRA r=32, alpha=64, target_modules = all 7 linear proj layers
  - max_length=224, "### Instruction / ### Input / ### Response" prompt format
  - Custom sliding-window completion collator (masks everything up to
    "### Response:\n" so loss is only computed on the Roman Urdu tokens)

Changed for this offline A16 16GB run:
  - No BitsAndBytesConfig / 4-bit quant (banned + caused errors before)
  - optim="adamw_torch" instead of "paged_adamw_8bit" (that optimizer requires
    bitsandbytes)
  - HF_HUB_OFFLINE / TRANSFORMERS_OFFLINE forced on, local_files_only=True
    everywhere, no push_to_hub

Run:
    python train_hbl_v4.py
================================================================================
"""

import os

# ------------------------------------------------------------------------
# MUST be set before importing transformers/datasets/huggingface_hub, so
# nothing on this air-gapped box ever attempts an HTTP call.
# ------------------------------------------------------------------------
os.environ["HF_HUB_OFFLINE"] = "1"
os.environ["TRANSFORMERS_OFFLINE"] = "1"
os.environ["HF_DATASETS_OFFLINE"] = "1"
os.environ["CUDA_VISIBLE_DEVICES"] = "0"
os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "expandable_segments:True"
os.environ["TOKENIZERS_PARALLELISM"] = "false"

import gc
import re
import glob
import random
import unicodedata

import numpy as np
import pandas as pd
import torch
from datasets import Dataset
from transformers import (
    AutoTokenizer,
    AutoModelForCausalLM,
    Trainer,
    TrainingArguments,
)
from peft import LoraConfig, get_peft_model, PeftModel

random.seed(42)
np.random.seed(42)

# ============================================================================
# CONFIG -- edit these paths for your environment
# ============================================================================

# Local path to the v2 merged HF model (full precision weights + tokenizer),
# copied onto this server. NOT the Ollama GGUF file -- must be the
# safetensors/config/tokenizer HF folder.
MODEL_PATH = "/opt/models/tiny-aya-hbl-v2"

# Local path to the HBL CSV dataset (columns: urdu,roman)
DATASET_PATH = "/opt/data/pairs_clean.csv"

# Dataset has ~137k rows. Adjust this manually to control training set size.
MAX_ROWS = 30000  # <-- adjust manually, dataset has 137k rows

# How many of MAX_ROWS to hold out for eval (taken from the sampled subset)
VAL_ROWS = 1500

# Working directory for checkpoints + LoRA adapter
RUN_DIR = "./tiny-aya-hbl-v4-run"
CHECKPOINT_DIR = os.path.join(RUN_DIR, "checkpoints")
ADAPTER_DIR = os.path.join(RUN_DIR, "final_adapter")

# Final merged full-precision HF model output (model.safetensors, config,
# tokenizer files) -- this is the v4 deliverable.
OUTPUT_DIR = "./tiny-aya-hbl-v4"

# Keep True to match the casing convention used in v2/v3 training
# (previous notebook's clean_roman() uppercased all targets).
# Set False if you want v4 to learn natural-case Roman Urdu instead.
UPPERCASE_ROMAN_TARGETS = True

MAX_SEQ_LEN = 224

# ============================================================================
# 1. DATA LOADING + CLEANING (mirrors notebook's clean_urdu / clean_roman)
# ============================================================================

def clean_urdu(s):
    if not isinstance(s, str) or len(s.strip()) == 0:
        return ""
    s = unicodedata.normalize("NFC", s)
    s = re.sub(r"\s+", " ", s).strip()
    s = re.sub(r",\s*,", ",", s)
    return s


def clean_roman(s):
    if not isinstance(s, str) or len(s.strip()) == 0:
        return ""
    s = unicodedata.normalize("NFC", s)
    s = re.sub(r"\s+", " ", s).strip()
    s = re.sub(r"\s*,\s*", ", ", s)
    s = re.sub(r"\s*\.\s*", ". ", s)
    s = re.sub(r"\s+", " ", s).strip()
    return s.upper() if UPPERCASE_ROMAN_TARGETS else s


def load_hbl_dataset(csv_path, max_rows, val_rows):
    print(f"Loading dataset: {csv_path}")
    df = pd.read_csv(csv_path)

    # Expect columns: urdu, roman
    missing = {"urdu", "roman"} - set(df.columns)
    if missing:
        raise ValueError(f"pairs_clean.csv missing expected columns: {missing}")

    df = df.rename(columns={"urdu": "Urdu_Input", "roman": "Roman_Urdu_Target"})
    df = df.dropna(subset=["Urdu_Input", "Roman_Urdu_Target"])

    print(f"Raw rows: {len(df):,}")

    df["Urdu_Input"] = df["Urdu_Input"].apply(clean_urdu)
    df["Roman_Urdu_Target"] = df["Roman_Urdu_Target"].apply(clean_roman)

    df = df[(df["Urdu_Input"].str.len() > 0) & (df["Roman_Urdu_Target"].str.len() > 0)]
    df = df.drop_duplicates(subset=["Urdu_Input", "Roman_Urdu_Target"])
    df = df[df["Urdu_Input"].str.len() <= 200]
    df = df[df["Roman_Urdu_Target"].str.len() <= 250]
    df = df.reset_index(drop=True)

    print(f"Rows after cleaning/dedup: {len(df):,}")

    if len(df) > max_rows:
        df = df.sample(n=max_rows, random_state=42).reset_index(drop=True)

    print(f"Rows used for this run (MAX_ROWS={max_rows:,}): {len(df):,}")

    val_rows = min(val_rows, max(1, len(df) // 20))
    val_df = df.sample(n=val_rows, random_state=42)
    train_df = df.drop(val_df.index).reset_index(drop=True)
    val_df = val_df.reset_index(drop=True)

    print(f"Train: {len(train_df):,} | Val: {len(val_df):,}")
    return train_df, val_df


def format_example(urdu, roman, eos_token):
    return f"""### Instruction:
Transliterate the following Urdu text into Roman Urdu.
Output ONLY the Roman Urdu. No translation. No explanation.

### Input:
{urdu}

### Response:
{roman}{eos_token}"""


# ============================================================================
# 2. MODEL + TOKENIZER (fp16, no quantization, no bitsandbytes)
# ============================================================================

def load_model_and_tokenizer(model_path):
    print(f"Loading base model (v2) from: {model_path}")

    tokenizer = AutoTokenizer.from_pretrained(model_path, local_files_only=True)
    tokenizer.pad_token = tokenizer.eos_token
    tokenizer.padding_side = "right"

    model = AutoModelForCausalLM.from_pretrained(
        model_path,
        torch_dtype=torch.float16,
        device_map={"": 0},
        trust_remote_code=True,
        local_files_only=True,
    )
    model.config.use_cache = False

    # No prepare_model_for_kbit_training here -- that helper is for
    # quantized (4-bit/8-bit) models only. For plain fp16 we just need
    # gradient checkpointing + input grads enabled manually.
    model.gradient_checkpointing_enable()
    model.enable_input_require_grads()

    lora_config = LoraConfig(
        r=32,
        lora_alpha=64,
        target_modules=[
            "q_proj",
            "k_proj",
            "v_proj",
            "o_proj",
            "gate_proj",
            "up_proj",
            "down_proj",
        ],
        lora_dropout=0.05,
        bias="none",
        task_type="CAUSAL_LM",
    )

    model = get_peft_model(model, lora_config)
    model.print_trainable_parameters()

    return model, tokenizer


# ============================================================================
# 3. TOKENIZATION + COMPLETION-ONLY MASKING COLLATOR
# ============================================================================

def build_tokenized_datasets(train_df, val_df, tokenizer):
    train_texts = [
        format_example(u, r, tokenizer.eos_token)
        for u, r in zip(train_df["Urdu_Input"], train_df["Roman_Urdu_Target"])
    ]
    val_texts = [
        format_example(u, r, tokenizer.eos_token)
        for u, r in zip(val_df["Urdu_Input"], val_df["Roman_Urdu_Target"])
    ]

    train_dataset = Dataset.from_dict({"text": train_texts})
    val_dataset = Dataset.from_dict({"text": val_texts})

    def tokenize_function(examples):
        return tokenizer(
            examples["text"],
            truncation=True,
            max_length=MAX_SEQ_LEN,
            padding=False,
            return_tensors=None,
        )

    tokenized_train = train_dataset.map(tokenize_function, batched=True, remove_columns=["text"])
    tokenized_val = val_dataset.map(tokenize_function, batched=True, remove_columns=["text"])

    print(f"Tokenized train samples: {len(tokenized_train):,}")
    print(f"Tokenized val samples: {len(tokenized_val):,}")
    print("\nSample formatted example:\n" + train_texts[0])

    return tokenized_train, tokenized_val


def make_completion_collator(tokenizer):
    response_template = "### Response:\n"
    response_ids = tokenizer.encode(response_template, add_special_tokens=False)
    window_size = len(response_ids)

    def custom_completion_collator(features):
        batch = tokenizer.pad(features, return_tensors="pt")
        labels = batch["input_ids"].clone()

        # Mask padding tokens
        labels[batch["attention_mask"] == 0] = -100

        # Mask the prompt: only train on tokens after "### Response:\n"
        for i in range(labels.shape[0]):
            label_seq = labels[i].tolist()
            match_idx = -1
            for j in range(len(label_seq) - window_size + 1):
                if label_seq[j:j + window_size] == response_ids:
                    match_idx = j + window_size
                    break
            if match_idx != -1:
                labels[i, :match_idx] = -100
            else:
                # Safety net: if template wasn't found (e.g. truncation cut
                # it off), don't train on a fully-unmasked prompt-only
                # sequence -- mask the whole thing instead.
                labels[i, :] = -100

        batch["labels"] = labels
        return batch

    return custom_completion_collator


# ============================================================================
# 4. TRAIN
# ============================================================================

def train(model, tokenizer, tokenized_train, tokenized_val):
    os.makedirs(CHECKPOINT_DIR, exist_ok=True)

    training_args = TrainingArguments(
        output_dir=CHECKPOINT_DIR,
        per_device_train_batch_size=4,
        per_device_eval_batch_size=4,
        gradient_accumulation_steps=4,
        num_train_epochs=1,
        learning_rate=2e-4,
        warmup_steps=200,
        logging_steps=10,
        eval_strategy="steps",
        eval_steps=500,
        eval_accumulation_steps=1,
        save_strategy="steps",
        save_steps=500,
        save_total_limit=2,
        load_best_model_at_end=False,
        fp16=True,
        report_to="none",
        dataloader_num_workers=2,
        remove_unused_columns=False,  # required for the custom collator
        optim="adamw_torch",          # NOT paged_adamw_8bit -- that needs bitsandbytes
        push_to_hub=False,
    )

    trainer = Trainer(
        model=model,
        args=training_args,
        train_dataset=tokenized_train,
        eval_dataset=tokenized_val,
        data_collator=make_completion_collator(tokenizer),
    )

    existing_checkpoints = glob.glob(os.path.join(CHECKPOINT_DIR, "checkpoint-*"))
    if existing_checkpoints:
        print(f"Found {len(existing_checkpoints)} existing checkpoints. Resuming...")
        trainer.train(resume_from_checkpoint=True)
    else:
        print("No checkpoints found. Starting fresh training run.")
        trainer.train()

    os.makedirs(ADAPTER_DIR, exist_ok=True)
    model.save_pretrained(ADAPTER_DIR)
    tokenizer.save_pretrained(ADAPTER_DIR)
    print(f"LoRA adapter saved to: {ADAPTER_DIR}")


# ============================================================================
# 5. MERGE ADAPTER INTO BASE (v2) MODEL -> v4 MERGED MODEL
# ============================================================================

def merge_and_save(base_model_path, adapter_path, output_dir):
    # Free GPU memory from the training run before reloading for merge.
    gc.collect()
    torch.cuda.empty_cache()

    print(f"Loading base model (v2) on CPU for safe merging: {base_model_path}")
    base_model = AutoModelForCausalLM.from_pretrained(
        base_model_path,
        torch_dtype=torch.float16,
        device_map="cpu",
        trust_remote_code=True,
        local_files_only=True,
    )
    tokenizer = AutoTokenizer.from_pretrained(base_model_path, local_files_only=True)

    print(f"Merging LoRA adapter from: {adapter_path}")
    merged_model = PeftModel.from_pretrained(base_model, adapter_path)
    merged_model = merged_model.merge_and_unload()

    os.makedirs(output_dir, exist_ok=True)
    merged_model.save_pretrained(output_dir, safe_serialization=True)
    tokenizer.save_pretrained(output_dir)

    print(f"v4 merged model saved to: {output_dir}")
    print("Contents:")
    for f in sorted(os.listdir(output_dir)):
        print(f"  {f}")


# ============================================================================
# 6. QUICK SANITY CHECK (optional, run after merge)
# ============================================================================

def quick_test(merged_model_path, sample_urdu_lines):
    print("\nRunning quick sanity check on merged v4 model...")
    tokenizer = AutoTokenizer.from_pretrained(merged_model_path, local_files_only=True)
    model = AutoModelForCausalLM.from_pretrained(
        merged_model_path,
        torch_dtype=torch.float16,
        device_map="auto",
        local_files_only=True,
    )
    model.eval()

    for urdu_text in sample_urdu_lines:
        prompt = f"""### Instruction:
Transliterate the following Urdu text into Roman Urdu.
Output ONLY the Roman Urdu. No translation. No explanation.

### Input:
{urdu_text}

### Response:
"""
        inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
        with torch.no_grad():
            outputs = model.generate(
                **inputs,
                max_new_tokens=128,
                do_sample=False,
                pad_token_id=tokenizer.eos_token_id,
            )
        generated_ids = outputs[0][inputs["input_ids"].shape[1]:]
        result = tokenizer.decode(generated_ids, skip_special_tokens=True).strip()
        print(f"\nUrdu:  {urdu_text}")
        print(f"Roman: {result}")


# ============================================================================
# MAIN
# ============================================================================

if __name__ == "__main__":
    print("=" * 70)
    print("Tiny-Aya-Global v2 -> v4 sequential fine-tuning (offline, FP16 LoRA)")
    print("=" * 70)
    print(f"GPU available: {torch.cuda.is_available()}")
    if torch.cuda.is_available():
        print(f"GPU: {torch.cuda.get_device_name(0)}")
        print(f"VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB")

    train_df, val_df = load_hbl_dataset(DATASET_PATH, MAX_ROWS, VAL_ROWS)

    model, tokenizer = load_model_and_tokenizer(MODEL_PATH)

    tokenized_train, tokenized_val = build_tokenized_datasets(train_df, val_df, tokenizer)

    train(model, tokenizer, tokenized_train, tokenized_val)

    # Free the LoRA-wrapped training model before reloading base for merge
    del model
    gc.collect()
    torch.cuda.empty_cache()

    merge_and_save(MODEL_PATH, ADAPTER_DIR, OUTPUT_DIR)

    quick_test(
        OUTPUT_DIR,
        sample_urdu_lines=[
            "آپ کے حبیب بینک لمیٹڈ اکاؤنٹ میں تنخواہ جمع ہو گئی ہے۔",
            "براہ کرم اس دستاویز پر تاریخ درج کریں۔",
        ],
    )

    print("\nDone. v4 model ready at:", OUTPUT_DIR)