oddadmix commited on
Commit
cd20eaf
·
verified ·
1 Parent(s): 3210708

Upload train.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. train.py +296 -0
train.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Full fine-tune of Whisper-small on the oddadmix multi-dialect Arabic set.
2
+
3
+ * Base: openai/whisper-small (244M), full fine-tune (no LoRA).
4
+ * Data: oddadmix/dialectal-arabic-lahgtna-v2-smaller-augmented
5
+ (already 16 kHz mono; has an `augmentation` column).
6
+ * Targets: cleaned with normalize.clean_text (tashkil + tags stripped).
7
+ * Features: log-mel computed ON THE FLY in the collator, so we never
8
+ materialize ~38 GB of cached features to disk.
9
+ * Precision: bf16 on a 32 GB GPU.
10
+
11
+ Auth: the dataset is private. Export a token first:
12
+ export HF_TOKEN=hf_xxx # account with access to oddadmix/...
13
+ Run:
14
+ uv run accelerate launch train.py # or: uv run python train.py
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ from dataclasses import dataclass
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ import torch
27
+ from datasets import load_dataset
28
+ import jiwer
29
+
30
+ from transformers import (
31
+ WhisperProcessor,
32
+ WhisperForConditionalGeneration,
33
+ Seq2SeqTrainer,
34
+ Seq2SeqTrainingArguments,
35
+ )
36
+
37
+ from normalize import clean_text
38
+
39
+ MAX_AUDIO_SECONDS = 30.0 # Whisper encoder hard limit
40
+ MIN_AUDIO_SECONDS = 0.5
41
+ MAX_LABEL_TOKENS = 448 # Whisper decoder max target length
42
+
43
+
44
+ def parse_args() -> argparse.Namespace:
45
+ p = argparse.ArgumentParser()
46
+ p.add_argument("--base_model", default="openai/whisper-small")
47
+ p.add_argument("--dataset", default="oddadmix/dialectal-arabic-lahgtna-v2-smaller-augmented")
48
+ p.add_argument("--language", default="ar")
49
+ p.add_argument("--run_name", default=None,
50
+ help="Name for this run. Outputs go to runs/<run_name>/. "
51
+ "Defaults to the base model name.")
52
+ p.add_argument("--output_dir", default=None,
53
+ help="Override output dir (default: runs/<run_name>).")
54
+ p.add_argument("--notes", default="",
55
+ help="Free-text note recorded in the run summary / README.")
56
+ p.add_argument("--per_device_train_batch_size", type=int, default=16)
57
+ p.add_argument("--per_device_eval_batch_size", type=int, default=8)
58
+ p.add_argument("--gradient_accumulation_steps", type=int, default=2)
59
+ p.add_argument("--learning_rate", type=float, default=1e-5)
60
+ p.add_argument("--warmup_steps", type=int, default=500)
61
+ p.add_argument("--max_steps", type=int, default=6000)
62
+ p.add_argument("--eval_steps", type=int, default=500)
63
+ p.add_argument("--save_steps", type=int, default=500)
64
+ p.add_argument("--num_workers", type=int, default=8)
65
+ p.add_argument("--normalize_letters", action="store_true",
66
+ help="Also fold أإآ->ا, ى->ي, ة->ه (off by default).")
67
+ p.add_argument("--eval_only_frac", type=float, default=1.0,
68
+ help="Use a fraction of the test split for periodic eval (speed).")
69
+ p.add_argument("--resume_from_checkpoint", default=None,
70
+ help="Path to a checkpoint dir to resume training from.")
71
+ return p.parse_args()
72
+
73
+
74
+ @dataclass
75
+ class DataCollator:
76
+ """Extract log-mel features from raw audio and tokenize cleaned labels."""
77
+
78
+ processor: WhisperProcessor
79
+ normalize_letters: bool
80
+ decoder_start_token_id: int
81
+
82
+ def __call__(self, batch: list[dict[str, Any]]) -> dict[str, torch.Tensor]:
83
+ fe = self.processor.feature_extractor
84
+ tok = self.processor.tokenizer
85
+
86
+ arrays = [ex["audio"]["array"] for ex in batch]
87
+ feats = fe(arrays, sampling_rate=16000, return_tensors="pt")
88
+ out = {"input_features": feats.input_features}
89
+
90
+ texts = [clean_text(ex["text"], self.normalize_letters) for ex in batch]
91
+ # tok(texts) prepends the Whisper prefix (<|sot|><|ar|><|transcribe|>
92
+ # <|notimestamps|>) and appends <|eot|>. The Trainer re-prepends the
93
+ # decoder-start token, so we strip the leading <|sot|> below.
94
+ # truncation is a safety net; over-length rows are filtered out in main()
95
+ label_ids = tok(texts, max_length=MAX_LABEL_TOKENS, truncation=True).input_ids
96
+ labels = tok.pad({"input_ids": label_ids}, return_tensors="pt")
97
+ # mask padding so it's ignored by the loss
98
+ mask = labels.attention_mask.ne(1)
99
+ labels_ids = labels.input_ids.masked_fill(mask, -100)
100
+ # The tokenizer prepends <|startoftranscript|> (== decoder_start_token_id).
101
+ # The Trainer re-prepends it when building decoder_input_ids, so strip it
102
+ # here to avoid a doubled start token. NOTE: Whisper's bos_token_id is
103
+ # <|endoftext|> (50257), NOT the sot token, so we must compare against
104
+ # decoder_start_token_id explicitly.
105
+ if (labels_ids[:, 0] == self.decoder_start_token_id).all().cpu().item():
106
+ labels_ids = labels_ids[:, 1:]
107
+ out["labels"] = labels_ids
108
+ return out
109
+
110
+
111
+ def build_metrics(processor, normalize_letters):
112
+ tok = processor.tokenizer
113
+
114
+ def compute_metrics(pred):
115
+ pred_ids = pred.predictions
116
+ label_ids = pred.label_ids
117
+ label_ids[label_ids == -100] = tok.pad_token_id
118
+
119
+ pred_str = tok.batch_decode(pred_ids, skip_special_tokens=True)
120
+ ref_str = tok.batch_decode(label_ids, skip_special_tokens=True)
121
+
122
+ preds = [clean_text(p, normalize_letters) for p in pred_str]
123
+ refs = [clean_text(r, normalize_letters) for r in ref_str]
124
+ # jiwer needs non-empty references; drop any degenerate pairs
125
+ pairs = [(p, r) for p, r in zip(preds, refs) if r.strip()]
126
+ if not pairs:
127
+ return {"wer": 1.0, "cer": 1.0}
128
+ preds, refs = map(list, zip(*pairs))
129
+ return {
130
+ "wer": jiwer.wer(refs, preds),
131
+ "cer": jiwer.cer(refs, preds),
132
+ }
133
+
134
+ return compute_metrics
135
+
136
+
137
+ def keep_row(text: str, duration: float, normalize_letters: bool) -> bool:
138
+ if duration is None or not (MIN_AUDIO_SECONDS <= duration <= MAX_AUDIO_SECONDS):
139
+ return False
140
+ return bool(clean_text(text, normalize_letters))
141
+
142
+
143
+ def main() -> None:
144
+ args = parse_args()
145
+
146
+ run_name = args.run_name or Path(args.base_model).name
147
+ output_dir = args.output_dir or f"runs/{run_name}"
148
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
149
+ print(f"run '{run_name}' -> {output_dir}")
150
+
151
+ processor = WhisperProcessor.from_pretrained(
152
+ args.base_model, language=args.language, task="transcribe"
153
+ )
154
+
155
+ ds = load_dataset(args.dataset)
156
+ # Filter on text+duration only -> no audio decode during filtering.
157
+ nl = args.normalize_letters
158
+ ds = ds.filter(
159
+ lambda text, duration: keep_row(text, duration, nl),
160
+ input_columns=["text", "duration"],
161
+ num_proc=args.num_workers,
162
+ )
163
+ # Drop rows whose (cleaned) transcript exceeds Whisper's 448-token target
164
+ # limit. These are corrupt/mislabeled clips (e.g. a huge text blob on a
165
+ # short clip) and would crash training. Tokenizing text is cheap; keep it
166
+ # single-process to avoid fast-tokenizer fork deadlocks.
167
+ tok = processor.tokenizer
168
+ before = {split: len(ds[split]) for split in ds}
169
+ ds = ds.filter(
170
+ lambda text: len(tok(clean_text(text, nl)).input_ids) <= MAX_LABEL_TOKENS,
171
+ input_columns=["text"],
172
+ num_proc=1,
173
+ )
174
+ after = {split: len(ds[split]) for split in ds}
175
+ print(f"kept {after} (dropped over-length: "
176
+ f"{ {s: before[s] - after[s] for s in before} })")
177
+
178
+ eval_ds = ds["test"]
179
+ if args.eval_only_frac < 1.0:
180
+ n = max(1, int(len(eval_ds) * args.eval_only_frac))
181
+ eval_ds = eval_ds.select(range(n))
182
+
183
+ # Force fp32 load: some checkpoints (e.g. large-v3-turbo) ship in fp16, which
184
+ # crashes generate() at eval (no autocast -> fp16 weights vs fp32 features).
185
+ # bf16-mixed training autocasts from fp32 weights, same as small/medium.
186
+ model = WhisperForConditionalGeneration.from_pretrained(
187
+ args.base_model, torch_dtype=torch.float32)
188
+ # Standard Whisper fine-tuning: let the model learn the language, don't
189
+ # force decoder ids or suppress tokens during training.
190
+ model.config.forced_decoder_ids = None
191
+ model.config.suppress_tokens = []
192
+ model.generation_config.language = args.language
193
+ model.generation_config.task = "transcribe"
194
+ model.generation_config.forced_decoder_ids = None
195
+ model.config.use_cache = False # required with gradient checkpointing
196
+
197
+ collator = DataCollator(
198
+ processor=processor,
199
+ normalize_letters=nl,
200
+ decoder_start_token_id=model.config.decoder_start_token_id,
201
+ )
202
+
203
+ training_args = Seq2SeqTrainingArguments(
204
+ output_dir=output_dir,
205
+ run_name=run_name,
206
+ per_device_train_batch_size=args.per_device_train_batch_size,
207
+ per_device_eval_batch_size=args.per_device_eval_batch_size,
208
+ gradient_accumulation_steps=args.gradient_accumulation_steps,
209
+ learning_rate=args.learning_rate,
210
+ warmup_steps=args.warmup_steps,
211
+ max_steps=args.max_steps,
212
+ gradient_checkpointing=True,
213
+ bf16=True,
214
+ fp16=False,
215
+ eval_strategy="steps",
216
+ eval_steps=args.eval_steps,
217
+ save_steps=args.save_steps,
218
+ logging_steps=25,
219
+ report_to=["tensorboard"],
220
+ predict_with_generate=True,
221
+ generation_max_length=225,
222
+ save_total_limit=3,
223
+ load_best_model_at_end=True,
224
+ metric_for_best_model="wer",
225
+ greater_is_better=False,
226
+ dataloader_num_workers=args.num_workers,
227
+ remove_unused_columns=False, # collator needs the raw 'audio' column
228
+ label_names=["labels"],
229
+ )
230
+
231
+ trainer = Seq2SeqTrainer(
232
+ model=model,
233
+ args=training_args,
234
+ train_dataset=ds["train"],
235
+ eval_dataset=eval_ds,
236
+ data_collator=collator,
237
+ compute_metrics=build_metrics(processor, nl),
238
+ processing_class=processor,
239
+ )
240
+
241
+ trainer.train(resume_from_checkpoint=args.resume_from_checkpoint)
242
+ trainer.save_model(output_dir)
243
+ processor.save_pretrained(output_dir)
244
+
245
+ # --- versioned run summary -------------------------------------------
246
+ evals = [h for h in trainer.state.log_history if "eval_wer" in h]
247
+ best = min(evals, key=lambda h: h["eval_wer"]) if evals else {}
248
+ train_logs = [h for h in trainer.state.log_history if "train_runtime" in h]
249
+ summary = {
250
+ "run_name": run_name,
251
+ "base_model": args.base_model,
252
+ "dataset": args.dataset,
253
+ "language": args.language,
254
+ "output_dir": output_dir,
255
+ "notes": args.notes,
256
+ "hyperparams": {
257
+ "learning_rate": args.learning_rate,
258
+ "warmup_steps": args.warmup_steps,
259
+ "max_steps": args.max_steps,
260
+ "per_device_train_batch_size": args.per_device_train_batch_size,
261
+ "gradient_accumulation_steps": args.gradient_accumulation_steps,
262
+ "effective_batch_size": args.per_device_train_batch_size
263
+ * args.gradient_accumulation_steps,
264
+ "normalize_letters": args.normalize_letters,
265
+ },
266
+ "train_examples": len(ds["train"]),
267
+ "eval_examples": len(eval_ds),
268
+ "best_wer": round(best.get("eval_wer", float("nan")), 4),
269
+ "best_cer": round(best.get("eval_cer", float("nan")), 4),
270
+ "best_step": best.get("step"),
271
+ "best_epoch": round(best.get("epoch", 0), 2),
272
+ "best_checkpoint": trainer.state.best_model_checkpoint,
273
+ "train_runtime_sec": round(train_logs[-1]["train_runtime"]) if train_logs else None,
274
+ "finished_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
275
+ "eval_history": [
276
+ {"step": h["step"], "wer": round(h["eval_wer"], 4), "cer": round(h["eval_cer"], 4)}
277
+ for h in evals
278
+ ],
279
+ }
280
+ summary_path = Path(output_dir) / "summary.json"
281
+ summary_path.write_text(json.dumps(summary, indent=2, ensure_ascii=False))
282
+ print(f"Done. Model saved to {output_dir}")
283
+ print(f"Best WER {summary['best_wer']} / CER {summary['best_cer']} "
284
+ f"@ step {summary['best_step']}. Summary -> {summary_path}")
285
+
286
+ # refresh the README run log (best-effort)
287
+ try:
288
+ import log_runs
289
+ log_runs.update_readme()
290
+ print("README run log updated.")
291
+ except Exception as e: # never fail training over docs
292
+ print(f"(README auto-update skipped: {e})")
293
+
294
+
295
+ if __name__ == "__main__":
296
+ main()