ovinduG commited on
Commit
e55c8a4
·
verified ·
1 Parent(s): dd5d1c7

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .ipynb_checkpoints/README-checkpoint.md +86 -0
  2. README.md +86 -0
  3. inference.py +277 -0
  4. predictions.jsonl +0 -0
  5. requirements.txt +15 -0
  6. training/REPRODUCE.md +172 -0
  7. training/add_new_data.py +174 -0
  8. training/check_env.py +162 -0
  9. training/eval_sinhalammlu.py +396 -0
  10. training/make_predictions.py +487 -0
  11. training/mcq_dataset.py +639 -0
  12. training/merge_adapter.py +105 -0
  13. training/normalize_subjects.py +191 -0
  14. training/push_merged_to_hf.py +126 -0
  15. training/run_data_pipeline.sh +48 -0
  16. training/run_inference.sh +32 -0
  17. training/run_training.sh +34 -0
  18. training/sft_gemma4_mcq.py +282 -0
  19. training/unsloth_compiled_cache/.locks/.lock.AqlmLoraLinear_peft_forward.py +0 -0
  20. training/unsloth_compiled_cache/.locks/.lock.AwqLoraLinear_peft_forward.py +0 -0
  21. training/unsloth_compiled_cache/.locks/.lock.BatchNorm1d.py +0 -0
  22. training/unsloth_compiled_cache/.locks/.lock.BatchNorm2d.py +0 -0
  23. training/unsloth_compiled_cache/.locks/.lock.BatchNorm3d.py +0 -0
  24. training/unsloth_compiled_cache/.locks/.lock.BlockDiagonalLinear_peft_forward.py +0 -0
  25. training/unsloth_compiled_cache/.locks/.lock.Conv1d.py +0 -0
  26. training/unsloth_compiled_cache/.locks/.lock.Conv2d.py +0 -0
  27. training/unsloth_compiled_cache/.locks/.lock.Conv3d.py +0 -0
  28. training/unsloth_compiled_cache/.locks/.lock.ConvTranspose1d.py +0 -0
  29. training/unsloth_compiled_cache/.locks/.lock.ConvTranspose2d.py +0 -0
  30. training/unsloth_compiled_cache/.locks/.lock.ConvTranspose3d.py +0 -0
  31. training/unsloth_compiled_cache/.locks/.lock.GPTQLoraLinear_peft_forward.py +0 -0
  32. training/unsloth_compiled_cache/.locks/.lock.GroupNorm.py +0 -0
  33. training/unsloth_compiled_cache/.locks/.lock.LayerNorm.py +0 -0
  34. training/unsloth_compiled_cache/.locks/.lock.Linear4bit_peft_forward.py +0 -0
  35. training/unsloth_compiled_cache/.locks/.lock.Linear8bitLt_peft_forward.py +0 -0
  36. training/unsloth_compiled_cache/.locks/.lock.Linear_peft_forward.py +0 -0
  37. training/unsloth_compiled_cache/.locks/.lock.LoraParallelLinear_peft_forward.py +0 -0
  38. training/unsloth_compiled_cache/.locks/.lock.RMSNorm.py +0 -0
  39. training/unsloth_compiled_cache/.locks/.lock.TeLinear_peft_forward.py +0 -0
  40. training/unsloth_compiled_cache/.locks/.lock.UnslothCPOTrainer.py +0 -0
  41. training/unsloth_compiled_cache/.locks/.lock.UnslothDPOTrainer.py +0 -0
  42. training/unsloth_compiled_cache/.locks/.lock.UnslothDistillationTrainer.py +0 -0
  43. training/unsloth_compiled_cache/.locks/.lock.UnslothGKDTrainer.py +0 -0
  44. training/unsloth_compiled_cache/.locks/.lock.UnslothGOLDTrainer.py +0 -0
  45. training/unsloth_compiled_cache/.locks/.lock.UnslothGRPOTrainer.py +0 -0
  46. training/unsloth_compiled_cache/.locks/.lock.UnslothKTOTrainer.py +0 -0
  47. training/unsloth_compiled_cache/.locks/.lock.UnslothORPOTrainer.py +0 -0
  48. training/unsloth_compiled_cache/.locks/.lock.UnslothOnlineDPOTrainer.py +0 -0
  49. training/unsloth_compiled_cache/.locks/.lock.UnslothRLOOTrainer.py +0 -0
  50. training/unsloth_compiled_cache/.locks/.lock.UnslothRewardTrainer.py +0 -0
.ipynb_checkpoints/README-checkpoint.md ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Team QBitsLK – Sinhala MMLU Shared Task System
2
+
3
+ ## System summary
4
+ - **Base model(s):** `google/gemma-4-E4B` (text-only), with Sinhala continued pretraining, then LoRA supervised fine-tuning on examination-paper MCQs.
5
+ - **What we did:** We built a 7,872-question Sinhala multiple-choice dataset by extracting from publicly available Sri Lankan O/L and Grade 6–11 past papers, cleaning OCR artefacts and decontaminating against the released SinhalaMMLU test split. We continued pretraining Gemma 4 E4B on Sinhala text, then fine-tuned with LoRA (r=32) using answer-only loss, and merged the adapter back into the base weights. At inference each option is scored by per-option log-likelihood under a 5-prompt ensemble (Sinhala and English instructions, subject intro on/off, two option-order rotations) rather than by generation. Nothing is sampled.
6
+ - **Total inference parameters:** **7,996,157,418** (one merged model; ≤ 8B)
7
+ - **Extra data used for training or retrieval:** our own past-paper MCQ dataset, published at `SinhalaMMLU-SharedTask/team-qbits-mcq-data`. No retrieval, no external APIs, no closed models.
8
+
9
+ ## Models and resources in this submission
10
+ | Repo | What it is |
11
+ |---|---|
12
+ | `SinhalaMMLU-SharedTask/team-qbits` | code, inference script, final predictions (this repo) |
13
+ | `SinhalaMMLU-SharedTask/team-qbits-cpt-v3` | **the submitted model** — merged 16-bit, 7,996,157,418 params |
14
+ | `SinhalaMMLU-SharedTask/team-qbits-cpt-v3-lora` | the LoRA adapter for the above (r=32), for inspection |
15
+ | `SinhalaMMLU-SharedTask/team-qbits-base-v3b` | ablation — same recipe on the plain Gemma 4 E4B base, merged |
16
+ | `SinhalaMMLU-SharedTask/team-qbits-base-v3b-lora` | the LoRA adapter for the ablation |
17
+ | `SinhalaMMLU-SharedTask/team-qbits-mcq-data` | the SFT dataset (prompt/completion files) |
18
+
19
+ `inference.py` uses **only** `team-qbits-cpt-v3`. The other repos are provided for transparency and
20
+ are not part of the inference pipeline.
21
+
22
+ ## How to run
23
+ ```bash
24
+ pip install -r requirements.txt
25
+ python inference.py
26
+ ```
27
+ - **GPU used:** 1x NVIDIA A40 48 GB (any GPU with ≥ 20 GB should work; the model is 16 GB in bf16)
28
+ - **Approximate run time on the test set:** ~18 minutes for 2,238 questions (5 prompt variants × 4 options = ~44,800 scored candidates); peak VRAM ~27 GB
29
+ - **Anything else the organizers need to know:**
30
+ - The Gemma license must be accepted on the Hugging Face account used to run this. Our merged model is self-contained, so no separate base-model download is required.
31
+ - `inference.py` scores options by log-likelihood and never calls `generate`, so there is no sampling. With the fixed seed, repeated runs produce byte-identical `predictions.jsonl`.
32
+ - `VARIANTS` at the top of `inference.py` defines the prompt ensemble. **Changing that list changes the predictions**; it is set exactly as used for our final Codabench submission.
33
+ - The reference `util_prompt.py` hard-codes the *hard* instruction as "1, 2, 3, 4, 5", which was correct for the original five-option hard split. SinhalaMMLU 2.0 reduced hard questions to four options, so `fit_numbers()` derives the number list from the actual option count. Verified: 0 of 2,238 prompts disagree with their option count.
34
+
35
+ ## Results (Codabench)
36
+
37
+ | system | easy | medium | hard | **macro** |
38
+ |---|---|---|---|---|
39
+ | **submitted** — `team-qbits-cpt-v3`, 5-prompt ensemble | 58.10 | 62.56 | 52.79 | **57.81** |
40
+ | the same model with the adapter applied at load time instead of merged | 58.26 | 62.02 | 52.58 | 57.62 |
41
+ | ablation — `team-qbits-base-v3b` (plain Gemma 4 base), 5-prompt ensemble | 57.01 | 63.10 | 51.34 | 57.15 |
42
+ | ablation — plain base, single prompt, no ensemble | — | — | — | 55.66 |
43
+
44
+ `inference.py` reproduces the submitted row exactly: it loads the merged model, which is the system
45
+ we ship. Row 2 is the same fine-tune evaluated with the LoRA applied at load time (base + adapter).
46
+ That form is 8,069,557,738 parameters and therefore **exceeds the 8B cap**, so it is not the
47
+ submitted system; it is listed only because merging is mathematically equivalent yet not
48
+ bit-identical in bf16 (`W + BA` rounded once, versus `Wx + B(Ax)`), which resolves about 1% of
49
+ near-tied questions differently.
50
+
51
+ ## Files
52
+ - `inference.py` – the exact script that produced our final Codabench submission
53
+ - `predictions.jsonl` – the exact file we submitted to the final leaderboard (macro 57.81),
54
+ byte-for-byte reproducible by running `inference.py`
55
+ - `predictions_ablation_base_v3b.jsonl` – an earlier submission from the ablation model
56
+ (`team-qbits-base-v3b`, macro 57.15), included for transparency. To regenerate it, set
57
+ `MODEL_ID` to `SinhalaMMLU-SharedTask/team-qbits-base-v3b` and change nothing else.
58
+ - `requirements.txt` – package versions we used
59
+ - `training/` – training, fine-tuning and data-preparation code
60
+ - `sft_gemma4_mcq.py` – LoRA SFT with answer-only loss and candidate-scoring evaluation
61
+ - `mcq_dataset.py` – extraction cleanup, validation, decontamination, splitting, prompt building
62
+ - `add_new_data.py`, `normalize_subjects.py` – dataset assembly and subject-name alignment
63
+ - `merge_adapter.py`, `push_merged_to_hf.py` – adapter merging and publishing
64
+ - `eval_sinhalammlu.py` – read-only analysis; also builds the decontamination blocklist
65
+ - `check_env.py` – dependency verification
66
+ - `run_data_pipeline.sh`, `run_training.sh`, `run_inference.sh` – end-to-end drivers
67
+ - `REPRODUCE.md` – exact commands, expected counts and the training curve
68
+
69
+ ## Data and decontamination
70
+ Source: publicly available Sri Lankan examination papers (`pastpapers.wiki`,
71
+ `e-thaksalawa.moe.gov.lk`), O/L and Grades 6–11. We do not redistribute the source PDFs; the derived
72
+ question set is published at `team-qbits-mcq-data`.
73
+
74
+ The released `TEST/easy` split was used **only** as a decontamination blocklist. It matched and
75
+ removed **26** questions from our training data. It was never used for training, checkpoint
76
+ selection, prompt design or hyperparameter choice. Model selection used a held-out split of our own
77
+ data, grouped so that no source paper appears in both training and validation.
78
+
79
+ ## Citation
80
+ This work evaluates on the SinhalaMMLU benchmark:
81
+
82
+ > Ashmari Pramodya, Nirasha Nelki, Heshan Shalinda, Chamila Liyanage, Yusuke Sakai, Randil
83
+ > Pushpananda, Ruvan Weerasinghe, Hidetaka Kamigaito, and Taro Watanabe. 2025. **SinhalaMMLU: A
84
+ > Comprehensive Benchmark for Evaluating Multitask Language Understanding in Sinhala.** In
85
+ > *Proceedings of the 2025 Conference on Empirical Methods in Natural Language Processing*, pages
86
+ > 32943–32961. https://aclanthology.org/2025.emnlp-main.1673/
README.md ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Team QBitsLK – Sinhala MMLU Shared Task System
2
+
3
+ ## System summary
4
+ - **Base model(s):** `google/gemma-4-E4B` (text-only), with Sinhala continued pretraining, then LoRA supervised fine-tuning on examination-paper MCQs.
5
+ - **What we did:** We built a 7,872-question Sinhala multiple-choice dataset by extracting from publicly available Sri Lankan O/L and Grade 6–11 past papers, cleaning OCR artefacts and decontaminating against the released SinhalaMMLU test split. We continued pretraining Gemma 4 E4B on Sinhala text, then fine-tuned with LoRA (r=32) using answer-only loss, and merged the adapter back into the base weights. At inference each option is scored by per-option log-likelihood under a 5-prompt ensemble (Sinhala and English instructions, subject intro on/off, two option-order rotations) rather than by generation. Nothing is sampled.
6
+ - **Total inference parameters:** **7,996,157,418** (one merged model; ≤ 8B)
7
+ - **Extra data used for training or retrieval:** our own past-paper MCQ dataset, published at `SinhalaMMLU-SharedTask/team-qbits-mcq-data`. No retrieval, no external APIs, no closed models.
8
+
9
+ ## Models and resources in this submission
10
+ | Repo | What it is |
11
+ |---|---|
12
+ | `SinhalaMMLU-SharedTask/team-qbits` | code, inference script, final predictions (this repo) |
13
+ | `SinhalaMMLU-SharedTask/team-qbits-cpt-v3` | **the submitted model** — merged 16-bit, 7,996,157,418 params |
14
+ | `SinhalaMMLU-SharedTask/team-qbits-cpt-v3-lora` | the LoRA adapter for the above (r=32), for inspection |
15
+ | `SinhalaMMLU-SharedTask/team-qbits-base-v3b` | ablation — same recipe on the plain Gemma 4 E4B base, merged |
16
+ | `SinhalaMMLU-SharedTask/team-qbits-base-v3b-lora` | the LoRA adapter for the ablation |
17
+ | `SinhalaMMLU-SharedTask/team-qbits-mcq-data` | the SFT dataset (prompt/completion files) |
18
+
19
+ `inference.py` uses **only** `team-qbits-cpt-v3`. The other repos are provided for transparency and
20
+ are not part of the inference pipeline.
21
+
22
+ ## How to run
23
+ ```bash
24
+ pip install -r requirements.txt
25
+ python inference.py
26
+ ```
27
+ - **GPU used:** 1x NVIDIA A40 48 GB (any GPU with ≥ 20 GB should work; the model is 16 GB in bf16)
28
+ - **Approximate run time on the test set:** ~18 minutes for 2,238 questions (5 prompt variants × 4 options = ~44,800 scored candidates); peak VRAM ~27 GB
29
+ - **Anything else the organizers need to know:**
30
+ - The Gemma license must be accepted on the Hugging Face account used to run this. Our merged model is self-contained, so no separate base-model download is required.
31
+ - `inference.py` scores options by log-likelihood and never calls `generate`, so there is no sampling. With the fixed seed, repeated runs produce byte-identical `predictions.jsonl`.
32
+ - `VARIANTS` at the top of `inference.py` defines the prompt ensemble. **Changing that list changes the predictions**; it is set exactly as used for our final Codabench submission.
33
+ - The reference `util_prompt.py` hard-codes the *hard* instruction as "1, 2, 3, 4, 5", which was correct for the original five-option hard split. SinhalaMMLU 2.0 reduced hard questions to four options, so `fit_numbers()` derives the number list from the actual option count. Verified: 0 of 2,238 prompts disagree with their option count.
34
+
35
+ ## Results (Codabench)
36
+
37
+ | system | easy | medium | hard | **macro** |
38
+ |---|---|---|---|---|
39
+ | **submitted** — `team-qbits-cpt-v3`, 5-prompt ensemble | 58.10 | 62.56 | 52.79 | **57.81** |
40
+ | the same model with the adapter applied at load time instead of merged | 58.26 | 62.02 | 52.58 | 57.62 |
41
+ | ablation — `team-qbits-base-v3b` (plain Gemma 4 base), 5-prompt ensemble | 57.01 | 63.10 | 51.34 | 57.15 |
42
+ | ablation — plain base, single prompt, no ensemble | — | — | — | 55.66 |
43
+
44
+ `inference.py` reproduces the submitted row exactly: it loads the merged model, which is the system
45
+ we ship. Row 2 is the same fine-tune evaluated with the LoRA applied at load time (base + adapter).
46
+ That form is 8,069,557,738 parameters and therefore **exceeds the 8B cap**, so it is not the
47
+ submitted system; it is listed only because merging is mathematically equivalent yet not
48
+ bit-identical in bf16 (`W + BA` rounded once, versus `Wx + B(Ax)`), which resolves about 1% of
49
+ near-tied questions differently.
50
+
51
+ ## Files
52
+ - `inference.py` – the exact script that produced our final Codabench submission
53
+ - `predictions.jsonl` – the exact file we submitted to the final leaderboard (macro 57.81),
54
+ byte-for-byte reproducible by running `inference.py`
55
+ - `predictions_ablation_base_v3b.jsonl` – an earlier submission from the ablation model
56
+ (`team-qbits-base-v3b`, macro 57.15), included for transparency. To regenerate it, set
57
+ `MODEL_ID` to `SinhalaMMLU-SharedTask/team-qbits-base-v3b` and change nothing else.
58
+ - `requirements.txt` – package versions we used
59
+ - `training/` – training, fine-tuning and data-preparation code
60
+ - `sft_gemma4_mcq.py` – LoRA SFT with answer-only loss and candidate-scoring evaluation
61
+ - `mcq_dataset.py` – extraction cleanup, validation, decontamination, splitting, prompt building
62
+ - `add_new_data.py`, `normalize_subjects.py` – dataset assembly and subject-name alignment
63
+ - `merge_adapter.py`, `push_merged_to_hf.py` – adapter merging and publishing
64
+ - `eval_sinhalammlu.py` – read-only analysis; also builds the decontamination blocklist
65
+ - `check_env.py` – dependency verification
66
+ - `run_data_pipeline.sh`, `run_training.sh`, `run_inference.sh` – end-to-end drivers
67
+ - `REPRODUCE.md` – exact commands, expected counts and the training curve
68
+
69
+ ## Data and decontamination
70
+ Source: publicly available Sri Lankan examination papers (`pastpapers.wiki`,
71
+ `e-thaksalawa.moe.gov.lk`), O/L and Grades 6–11. We do not redistribute the source PDFs; the derived
72
+ question set is published at `team-qbits-mcq-data`.
73
+
74
+ The released `TEST/easy` split was used **only** as a decontamination blocklist. It matched and
75
+ removed **26** questions from our training data. It was never used for training, checkpoint
76
+ selection, prompt design or hyperparameter choice. Model selection used a held-out split of our own
77
+ data, grouped so that no source paper appears in both training and validation.
78
+
79
+ ## Citation
80
+ This work evaluates on the SinhalaMMLU benchmark:
81
+
82
+ > Ashmari Pramodya, Nirasha Nelki, Heshan Shalinda, Chamila Liyanage, Yusuke Sakai, Randil
83
+ > Pushpananda, Ruvan Weerasinghe, Hidetaka Kamigaito, and Taro Watanabe. 2025. **SinhalaMMLU: A
84
+ > Comprehensive Benchmark for Evaluating Multitask Language Understanding in Sinhala.** In
85
+ > *Proceedings of the 2025 Conference on Empirical Methods in Natural Language Processing*, pages
86
+ > 32943–32961. https://aclanthology.org/2025.emnlp-main.1673/
inference.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sinhala MMLU Shared Task - Final inference script
3
+ Team: QBitsLK
4
+
5
+ The organizers will run this file exactly as it is, changing ONLY the DATA_REPO line
6
+ below to point to the final Sinhala MMLU dataset:
7
+
8
+ pip install -r requirements.txt
9
+ python inference.py
10
+
11
+ System: one merged 16-bit model (7,996,157,418 parameters, <= 8B). Gemma 4 E4B with Sinhala
12
+ continued pretraining, LoRA-fine-tuned on past-paper MCQs and merged back into the base weights.
13
+
14
+ Answers are chosen by per-option log-likelihood under a 5-prompt ensemble. No text is generated,
15
+ nothing is sampled, and the seed is fixed, so a re-run gives identical answers.
16
+ """
17
+
18
+ # ======================= ORGANIZERS CHANGE ONLY THIS LINE =======================
19
+ DATA_REPO = "SinhalaMMLU-SharedTask/Sinhala-MMLU-SharedTask-Test-Dataset"
20
+ # ================================================================================
21
+
22
+ OUTPUT_FILE = "predictions.jsonl"
23
+
24
+ # ----------------------- TEAM SETTINGS (keep exactly as used for your final submission)
25
+ MODEL_ID = "SinhalaMMLU-SharedTask/team-qbits-cpt-v3" # merged model, self-contained
26
+ SEED = 42
27
+ BATCH = 16 # candidate sequences scored per forward pass
28
+ MAX_SEQ_LEN = 1024
29
+
30
+ # Prompt variants the ensemble averages over. Each is a configuration the organizers' own
31
+ # reference harness supports (sinhala/english x subject-intro on/off), plus two rotations of the
32
+ # option order to cancel answer-position bias. CHANGING THIS LIST CHANGES THE PREDICTIONS.
33
+ VARIANTS = [
34
+ {"lang": "si", "intro": True, "rot": 0},
35
+ {"lang": "si", "intro": False, "rot": 0},
36
+ {"lang": "en", "intro": True, "rot": 0},
37
+ {"lang": "si", "intro": True, "rot": 1},
38
+ {"lang": "si", "intro": True, "rot": 2},
39
+ ]
40
+
41
+ import json
42
+ import math
43
+ import os
44
+ import random
45
+ import re
46
+
47
+ import numpy as np
48
+ import torch
49
+ from huggingface_hub import HfFileSystem, list_repo_files
50
+
51
+ os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
52
+
53
+
54
+ def set_seed(seed):
55
+ random.seed(seed)
56
+ np.random.seed(seed)
57
+ torch.manual_seed(seed)
58
+ torch.cuda.manual_seed_all(seed)
59
+
60
+
61
+ # =============== 1. LOAD THE DATASET FROM HUGGING FACE (do not change) ===============
62
+ def load_questions(data_repo):
63
+ fs = HfFileSystem()
64
+ files = sorted(f for f in list_repo_files(data_repo, repo_type="dataset") if f.endswith(".json"))
65
+ rows = []
66
+ for f in files:
67
+ with fs.open(f"datasets/{data_repo}/{f}", "r", encoding="utf-8") as fh:
68
+ raw = json.load(fh)
69
+ if isinstance(raw, dict):
70
+ raw = next(v for v in raw.values() if isinstance(v, list))
71
+ for r in raw:
72
+ r["_file"] = f
73
+ rows.append(r)
74
+ return rows
75
+
76
+
77
+ def get_level(r):
78
+ return (r.get("metadata") or {}).get("difficulty") or r["_file"].split("/")[0]
79
+
80
+
81
+ # =============== 2. OUR SYSTEM ===============
82
+ # The two function names are kept. predict_all() returns one answer ("1".."4") per question,
83
+ # in the same order as `rows`.
84
+
85
+ # Instruction text taken verbatim from the organizers' util_prompt.py.
86
+ SINHALA_INSTRUCTIONS = {
87
+ "easy": "පහත ප්‍රශ්නයට 1, 2, 3, 4 යන පිළිතුරුවලින් නිවැරදි හෝ ඉතාමත් ගැළපෙන පිළිතුර තෝරන්න.\n\n",
88
+ "medium": "පහත ප්‍රශ්නයට 1, 2, 3, 4 යන පිළිතුරුවලින් නිවැරදි හෝ ඉතාමත් ගැළපෙන පිළිතුර තෝරාගන්න.\n\n",
89
+ "hard": "පහත ප්‍රශ්නයට 1, 2, 3, 4, 5 යන පිළිතුරුවලින් නිවැරදි හෝ ඉතාමත් ගැළපෙන පිළිතුර තෝරාගන්න.\n\n",
90
+ }
91
+ ENGLISH_INSTRUCTIONS = {
92
+ "easy": "Choose the correct or most appropriate answer from 1, 2, 3, 4 for the question below.\n\n",
93
+ "medium": "Choose the correct or most appropriate answer from 1, 2, 3, 4 for the question below.\n\n",
94
+ "hard": "Select the correct answer from 1, 2, 3, 4, 5 for the given question.\n\n",
95
+ }
96
+
97
+
98
+ def fit_numbers(instruction, n):
99
+ """The reference prompt hard-codes "1, 2, 3, 4, 5" for the HARD level because the original
100
+ SinhalaMMLU hard split had five options. SinhalaMMLU 2.0 drops one incorrect option, so hard
101
+ questions have four. Telling the model to choose from five when four are shown is strictly
102
+ worse, so the number run is rewritten to match the actual option count."""
103
+ return re.sub(r"1,\s*2,\s*3,\s*4(?:,\s*5)?",
104
+ ", ".join(str(i + 1) for i in range(n)), instruction, count=1)
105
+
106
+
107
+ def build_prompt(level, subject, subject_original, question, choices, spec):
108
+ """Returns (prompt, rot). Displayed position j holds original option (j + rot) % n."""
109
+ n = len(choices)
110
+ rot = spec["rot"] % n if n else 0
111
+ shown = choices[rot:] + choices[:rot]
112
+ body = "\n".join(f"{i + 1}. {str(c).strip()}" for i, c in enumerate(shown))
113
+ if spec["lang"] == "en":
114
+ intro = (f"This is a multiple choice question related to the subject {subject}. "
115
+ "Given in Sinhala Language.\n") if spec["intro"] else ""
116
+ instr = fit_numbers(ENGLISH_INSTRUCTIONS[level], n)
117
+ return intro + instr + f"Question: {question}\n{body}\nAnswer:", rot
118
+ intro = f"මෙය {subject_original} විෂයයට අදාළ බහුවරණ ප්‍රශ්නයකි.\n" if spec["intro"] else ""
119
+ instr = fit_numbers(SINHALA_INSTRUCTIONS[level], n)
120
+ return intro + instr + f"ප්‍රශ්නය: {question}\n{body}\nපිළිතුර:", rot
121
+
122
+
123
+ def load_system():
124
+ """Loads the merged model. Gemma 4 is a multimodal architecture, so the right AutoClass
125
+ depends on the transformers version; we try the plausible ones in order."""
126
+ try:
127
+ from unsloth import FastModel
128
+ model, proc = FastModel.from_pretrained(model_name=MODEL_ID, dtype=None,
129
+ max_seq_length=MAX_SEQ_LEN,
130
+ load_in_4bit=False, full_finetuning=False)
131
+ tok = getattr(proc, "tokenizer", proc)
132
+ if getattr(tok, "pad_token", None) is None:
133
+ tok.pad_token = tok.eos_token
134
+ model.eval()
135
+ print("[model] loaded with unsloth.FastModel (the loader used for our submission)")
136
+ return tok, model
137
+ except Exception as e:
138
+ print(f"[model] unsloth unavailable or failed ({type(e).__name__}: {e});"
139
+ " falling back to plain transformers - expect ~1% of answers to differ")
140
+
141
+ from transformers import AutoConfig, AutoProcessor, AutoTokenizer
142
+
143
+ try:
144
+ proc = AutoProcessor.from_pretrained(MODEL_ID)
145
+ tok = getattr(proc, "tokenizer", proc)
146
+ except Exception:
147
+ tok = AutoTokenizer.from_pretrained(MODEL_ID)
148
+ if getattr(tok, "pad_token", None) is None:
149
+ tok.pad_token = tok.eos_token
150
+
151
+ cfg = AutoConfig.from_pretrained(MODEL_ID)
152
+ print("[model] architectures:", getattr(cfg, "architectures", None))
153
+
154
+ candidates = []
155
+ import transformers
156
+ for name in ("AutoModelForCausalLM", "AutoModelForImageTextToText",
157
+ "AutoModelForVision2Seq", "AutoModel"):
158
+ if hasattr(transformers, name):
159
+ candidates.append(getattr(transformers, name))
160
+ last = None
161
+ for cls in candidates:
162
+ try:
163
+ model = cls.from_pretrained(MODEL_ID, dtype=torch.bfloat16, device_map="auto")
164
+ print(f"[model] loaded with {cls.__name__}")
165
+ break
166
+ except Exception as e: # noqa: BLE001
167
+ last = e
168
+ print(f"[model] {cls.__name__} failed: {type(e).__name__}")
169
+ else:
170
+ raise RuntimeError(f"could not load {MODEL_ID}: {last}")
171
+ model.eval()
172
+ return tok, model
173
+
174
+
175
+ def _common_prefix_len(a, b):
176
+ n = 0
177
+ for x, y in zip(a, b):
178
+ if x != y:
179
+ break
180
+ n += 1
181
+ return n
182
+
183
+
184
+ def predict_all(system, rows):
185
+ """For every (question, variant, option) the answer token is scored; within each variant the
186
+ option scores become a log-softmax over displayed positions (putting variants of different
187
+ prompt lengths on one scale), are mapped back to the original option order, and summed."""
188
+ tok, model = system
189
+ dev = next(model.parameters()).device
190
+ pad = tok.pad_token_id if tok.pad_token_id is not None else 0
191
+
192
+ items, meta = [], {}
193
+ for ri, r in enumerate(rows):
194
+ choices = [c for c in (r.get("choices") or []) if str(c).strip()]
195
+ if len(choices) < 2:
196
+ continue
197
+ level = get_level(r)
198
+ if level not in SINHALA_INSTRUCTIONS:
199
+ level = "easy"
200
+ md = r.get("metadata") or {}
201
+ for vi, spec in enumerate(VARIANTS):
202
+ prompt, rot = build_prompt(level, r.get("subject"),
203
+ md.get("subject_original") or r.get("subject"),
204
+ str(r.get("question") or "").strip(), choices, spec)
205
+ meta[(ri, vi)] = (rot, len(choices))
206
+ p_ids = tok(prompt)["input_ids"]
207
+ for j in range(len(choices)):
208
+ f = tok(prompt + f" {j + 1}")["input_ids"]
209
+ if len(f) > MAX_SEQ_LEN:
210
+ f = f[-MAX_SEQ_LEN:]
211
+ items.append((ri, vi, j, f, max(1, _common_prefix_len(p_ids, f))))
212
+
213
+ print(f"{len(rows)} questions x {len(VARIANTS)} variants = {len(items)} candidates")
214
+ scores = {}
215
+ with torch.no_grad():
216
+ for i in range(0, len(items), BATCH):
217
+ chunk = items[i:i + BATCH]
218
+ L = max(len(x[3]) for x in chunk)
219
+ ids = torch.full((len(chunk), L), pad, dtype=torch.long)
220
+ mask = torch.zeros((len(chunk), L), dtype=torch.long)
221
+ for b, (_, _, _, sq, _) in enumerate(chunk):
222
+ ids[b, :len(sq)] = torch.tensor(sq)
223
+ mask[b, :len(sq)] = 1
224
+ out = model(input_ids=ids.to(dev), attention_mask=mask.to(dev))
225
+ logits = out.logits if hasattr(out, "logits") else out[0]
226
+ for b, (ri, vi, j, sq, k) in enumerate(chunk):
227
+ lp = logits[b, k - 1:len(sq) - 1].float().log_softmax(-1)
228
+ tgt = ids[b, k:len(sq)].to(dev)
229
+ scores[(ri, vi, j)] = lp.gather(-1, tgt[:, None]).sum().item()
230
+ del logits, out
231
+ print(f"{min(i + BATCH, len(items))}/{len(items)}", end="\r")
232
+
233
+ answers = []
234
+ for ri, r in enumerate(rows):
235
+ n = len([c for c in (r.get("choices") or []) if str(c).strip()])
236
+ if n < 2:
237
+ answers.append("1") # a prediction is still required
238
+ continue
239
+ total, used = [0.0] * n, 0
240
+ for vi in range(len(VARIANTS)):
241
+ if (ri, vi) not in meta:
242
+ continue
243
+ rot, nv = meta[(ri, vi)]
244
+ raw = [scores.get((ri, vi, j)) for j in range(nv)]
245
+ if any(x is None for x in raw):
246
+ continue
247
+ m = max(raw)
248
+ z = m + math.log(sum(math.exp(x - m) for x in raw))
249
+ for j in range(nv):
250
+ total[(j + rot) % n] += raw[j] - z
251
+ used += 1
252
+ answers.append("1" if used == 0 else str(1 + max(range(n), key=lambda i: total[i])))
253
+ return answers
254
+
255
+
256
+ # =============== 3. WRITE PREDICTIONS (do not change) ===============
257
+ if __name__ == "__main__":
258
+ set_seed(SEED)
259
+ rows = load_questions(DATA_REPO)
260
+ print(len(rows), "questions loaded from", DATA_REPO)
261
+
262
+ system = load_system()
263
+ answers = predict_all(system, rows)
264
+
265
+ assert len(answers) == len(rows), "predict_all() must return one answer per question"
266
+ with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
267
+ for r, a in zip(rows, answers):
268
+ a = str(a).strip()
269
+ assert a in {"1", "2", "3", "4"}, f"invalid answer {a!r} for q_no={r.get('q_no')}"
270
+ f.write(json.dumps({
271
+ "q_no": r["q_no"],
272
+ "subject": r["subject"],
273
+ "category": r["category"],
274
+ "level": get_level(r),
275
+ "answer": a,
276
+ }, ensure_ascii=False, separators=(",", ":")) + "\n")
277
+ print("\nSaved", OUTPUT_FILE)
predictions.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ unsloth
2
+ # Sinhala MMLU Shared Task - Team QBitsLK
3
+ # Verified install: pip install -r requirements.txt && python inference.py
4
+ #
5
+ # Inference needs ONE GPU with >= 20 GB of memory (the model is 16 GB in bf16).
6
+ # Reference machine: 1x NVIDIA A40 48 GB, driver 595.91, CUDA 13.2, Python 3.12.
7
+
8
+ torch>=2.6
9
+ transformers==5.5.0
10
+ tokenizers>=0.22.0,<=0.23.0
11
+ huggingface_hub>=1.5.0,<2.0
12
+ accelerate>=1.0
13
+ timm # Gemma 4 ships a multimodal processor; loading it requires timm
14
+ numpy
15
+ hf_transfer # faster weight download; optional
training/REPRODUCE.md ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Reproducing our SinhalaMMLU Shared Task submission
2
+
3
+ Team: QBitsLK · ICTer 2026 SinhalaMMLU Shared Task
4
+
5
+ Everything below runs with no human in the loop. Inference is deterministic: no sampling, no
6
+ generation, fixed seeds, and the prediction is an argmax over summed log-softmax scores.
7
+
8
+ ## 1. Quick start — reproduce the submitted predictions
9
+
10
+ ```bash
11
+ pip install unsloth
12
+ pip install --no-deps transformers==5.5.0 "tokenizers>=0.22.0,<=0.23.0"
13
+ pip install "huggingface_hub>=1.5.0,<2.0"
14
+ pip install --no-deps --upgrade timm
15
+ pip install hf_transfer
16
+
17
+ python check_env.py # verifies the whole stack; exits non-zero on a problem
18
+
19
+ bash run_inference.sh /path/to/test_root ./out
20
+ ```
21
+
22
+ `test_root` is the directory holding `easy/`, `medium/` and `hard/`. The script writes
23
+ `out/predictions.jsonl` and `out/predictions.zip` (with `predictions.jsonl` at the archive root).
24
+
25
+ Hardware used for every reported run: one **NVIDIA A40, 48 GB**, driver 595.91, CUDA 13.2.
26
+ Inference over the 2,238-question test set takes about **18 minutes** and peaks around 27 GB of VRAM.
27
+
28
+ ## 2. The submitted system in one line
29
+
30
+ | | |
31
+ |---|---|
32
+ | model | `QBitsLK/gemma4-e4b-sinhala-mcq-cpt-v3` (merged 16-bit) |
33
+ | parameters | **7,996,156,448** — the entire inference pipeline, one model, ≤ 8B |
34
+ | base | `QBitsLK/gemma4-e4b-sinhala-cpt` (Gemma 4 E4B + Sinhala continued pretraining) |
35
+ | adapter | LoRA r=32, α=32, merged into the base; merging adds no parameters |
36
+ | scoring | per-option log-likelihood, 5-prompt ensemble, argmax |
37
+ | prompt variants | `si_intro si_nointro en_intro si_rot1 si_rot2` |
38
+ | seed | 3407 |
39
+
40
+ The parameter count can be checked without downloading the weights:
41
+
42
+ ```bash
43
+ python -c "
44
+ from huggingface_hub import get_safetensors_metadata
45
+ t = sum(get_safetensors_metadata('QBitsLK/gemma4-e4b-sinhala-mcq-cpt-v3').parameter_count.values())
46
+ print(f'{t:,}', 'OK, <= 8B' if t <= 8_000_000_000 else 'OVER 8B')"
47
+ ```
48
+
49
+ **The adapter is merged on purpose.** Base + adapter loaded separately is 8,069,556,768 parameters,
50
+ which exceeds the cap; merging folds the LoRA into the existing weight matrices and returns the model
51
+ to 7,996,156,448.
52
+
53
+ ## 3. Results reproduced by this package
54
+
55
+ Codabench, hidden test set (2,238 questions: easy 642, medium 1,111, hard 485):
56
+
57
+ | run | easy | medium | hard | **macro** |
58
+ |---|---|---|---|---|
59
+ | **submitted** — CPT + v3 data, 5-prompt ensemble | 58.26 | 62.02 | **52.58** | **57.62** |
60
+ | ablation — base Gemma 4 + v3 data, 5-prompt ensemble | 57.01 | 63.10 | 51.34 | 57.15 |
61
+ | ablation — base Gemma 4 + v3 data, single prompt | — | — | — | 55.66 |
62
+
63
+ To reproduce the first ablation, change `MODEL` in `run_inference.sh` to
64
+ `QBitsLK/gemma4-e4b-sinhala-mcq-base-v3b`. For the second, set `VARIANTS="si_intro"`.
65
+
66
+ ## 4. Full reproduction from scratch
67
+
68
+ ### 4.1 Rebuild the training data (CPU, a few minutes)
69
+
70
+ ```bash
71
+ bash run_data_pipeline.sh
72
+ ```
73
+
74
+ Requires the raw extraction files in `./data` (see section 5). Expected output: **7,872** questions
75
+ after deduplication, split into **7,151 train → 21,235 examples** and **721 validation**.
76
+
77
+ The public `TEST/easy` split is downloaded by this script and used **only** as a decontamination
78
+ blocklist. It removed **26 questions** that our past-paper extractions shared with the released test
79
+ data. It is never used for training, checkpoint selection, prompt design or hyperparameter choice.
80
+
81
+ ### 4.2 Re-run the fine-tune (1 GPU, 88 minutes)
82
+
83
+ ```bash
84
+ bash run_training.sh /workspace/outputs/mcq_sft_cpt_v3
85
+ ```
86
+
87
+ This downloads the published SFT files from
88
+ `https://huggingface.co/datasets/QBitsLK/sinhala-mcq-sft-v3`, so section 4.1 is optional if you only
89
+ want to reproduce training.
90
+
91
+ Expected validation curve (721 held-out questions, accuracy):
92
+
93
+ | step | 0 | 150 | 300 | 450 | 600 | 664 | 750 | 900 | 1050 | 1200 | 1328 |
94
+ |---|---|---|---|---|---|---|---|---|---|---|---|
95
+ | acc | .2316 | .5520 | .5395 | .5728 | .5506 | .5562 | .5867 | .5645 | .5742 | .5825 | **.5811** |
96
+
97
+ **The submitted checkpoint is `final_adapter` (step 1328), not `best_adapter`.** Rationale in
98
+ `SYSTEM_DESCRIPTION.md` §5.2: on a parallel run the validation-best checkpoint scored 0.7 points
99
+ *worse* on held-out test data than the final one, because our validation split shares the training
100
+ distribution and does not detect this failure mode.
101
+
102
+ ### 4.3 Publish the merged model
103
+
104
+ ```bash
105
+ export HF_TOKEN=...
106
+ python push_merged_to_hf.py \
107
+ --adapter /workspace/outputs/mcq_sft_cpt_v3/final_adapter \
108
+ --repo <your-namespace>/gemma4-e4b-sinhala-mcq-cpt-v3
109
+ ```
110
+
111
+ Streams the merge directly to the Hub, so no 16 GB local copy is needed, and reads the parameter
112
+ count back for the ≤8B check.
113
+
114
+ ## 5. Files
115
+
116
+ | file | purpose |
117
+ |---|---|
118
+ | `run_inference.sh` | reproduces the submitted predictions (the critical path) |
119
+ | `run_training.sh` | reproduces the fine-tune |
120
+ | `run_data_pipeline.sh` | rebuilds the SFT dataset from raw extractions |
121
+ | `make_predictions.py` | prompt builder, ensemble scorer, submission writer, format checks |
122
+ | `sft_gemma4_mcq.py` | LoRA SFT with answer-only loss; per-step candidate-scoring evaluation |
123
+ | `mcq_dataset.py` | extraction cleanup, validation, decontamination, splitting, prompt building |
124
+ | `add_new_data.py` | merges per-subject extractions into the dataset |
125
+ | `normalize_subjects.py` | aligns subject names with the official `metadata.subject_original` |
126
+ | `eval_sinhalammlu.py` | read-only analysis; also builds the decontamination blocklist |
127
+ | `push_merged_to_hf.py` | merges the adapter and publishes it |
128
+ | `check_env.py` | verifies the dependency stack before a run |
129
+ | `requirements.txt` | exact pins and install order |
130
+
131
+ **Not included:** the source examination PDFs. They are third-party past papers obtained from
132
+ publicly available sources (`pastpapers.wiki`, `e-thaksalawa.moe.gov.lk`), and we do not hold
133
+ redistribution rights. The derived question set — questions, options and answer keys — is published
134
+ at `QBitsLK/sinhala-mcq-sft-v3`, which is sufficient to reproduce training and inference. The raw
135
+ per-subject extraction files are included so `run_data_pipeline.sh` runs end-to-end.
136
+
137
+ ## 6. Determinism
138
+
139
+ - No sampling anywhere; `model.generate` is never called on the submission path.
140
+ - `random.seed(3407)` and `torch.manual_seed(3407)`.
141
+ - The prediction is `argmax` over log-softmax sums; ties resolve to the lowest option index.
142
+ - Prompt variants are explicit in `run_inference.sh`; changing them changes the predictions.
143
+ - `make_predictions.py` refuses to write the zip unless the line count equals the question count,
144
+ every line carries exactly `{q_no, subject, category, level, answer}`, `answer` is a `"1"`–`"5"`
145
+ string, and every `level` is `easy`/`medium`/`hard`.
146
+
147
+ One deliberate deviation from the organisers' reference code: `evaluate.py` hard-codes the *hard*
148
+ instruction as "1, 2, 3, 4, 5" because the original SinhalaMMLU hard split had five options.
149
+ SinhalaMMLU 2.0 drops one incorrect option, so hard questions now have four. `fit_numbers()` in
150
+ `make_predictions.py` rewrites the number list to match the actual option count (verified: 0 of 2,238
151
+ prompts disagree). Without this, all 485 hard questions would be asked to choose from five options
152
+ when only four are shown.
153
+
154
+ ## 7. Citation
155
+
156
+ This work evaluates on the SinhalaMMLU benchmark:
157
+
158
+ ```bibtex
159
+ @inproceedings{pramodya-etal-2025-sinhalammlu,
160
+ title = "SinhalaMMLU: A Comprehensive Benchmark for Evaluating Multitask Language Understanding in Sinhala",
161
+ author = "Pramodya, Ashmari and Nelki, Nirasha and Shalinda, Heshan and Liyanage, Chamila and
162
+ Sakai, Yusuke and Pushpananda, Randil and Weerasinghe, Ruvan and
163
+ Kamigaito, Hidetaka and Watanabe, Taro",
164
+ booktitle = "Proceedings of the 2025 Conference on Empirical Methods in Natural Language Processing",
165
+ year = "2025",
166
+ pages = "32943--32961",
167
+ url = "https://aclanthology.org/2025.emnlp-main.1673/"
168
+ }
169
+ ```
170
+
171
+ Verify this against the BibTeX on <https://sinhalammlu.ucsc.cmb.ac.lk/task.html> before submitting;
172
+ it is reproduced here from that page and the arXiv preprint (arXiv:2509.03162).
training/add_new_data.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Add newly extracted subject files to the MCQ dataset, safely.
4
+
5
+ Why a script instead of `cat a b > c`: on Windows PowerShell the `>` redirect re-encodes the text and
6
+ corrupts Sinhala. This reads and writes UTF-8 explicitly and validates every line.
7
+
8
+ It figures out each input file's format on its own:
9
+ * raw extraction format (original_question / answer_1..5 / correct_answer) -> runs mcq_dataset
10
+ convert on it (same cleaning, junk-stripping and filters as the original pipeline)
11
+ * MCQ schema (question / choices / answer) -> passed through
12
+ It then merges everything into one converted file, ready for clean -> dedupe -> split -> build-sft.
13
+
14
+ python add_new_data.py --base data/converted.jsonl \
15
+ --new data/art.jsonl data/buddhim.jsonl data/dancing.jsonl data/sinhala.jsonl \
16
+ --out data/all_converted.jsonl
17
+
18
+ Prints a per-file report (rows kept, subjects, exam types/grades) so you can see what you actually
19
+ added before spending GPU time. Put mcq_dataset.py in the same folder.
20
+ """
21
+ import argparse
22
+ import collections
23
+ import importlib.util
24
+ import json
25
+ import os
26
+ import sys
27
+ from argparse import Namespace
28
+
29
+ HERE = os.path.dirname(os.path.abspath(__file__))
30
+
31
+
32
+ def load_mcq_module():
33
+ path = os.path.join(HERE, "mcq_dataset.py")
34
+ if not os.path.exists(path):
35
+ sys.exit(f"mcq_dataset.py not found next to this script ({HERE})")
36
+ spec = importlib.util.spec_from_file_location("mcq_dataset", path)
37
+ m = importlib.util.module_from_spec(spec)
38
+ spec.loader.exec_module(m)
39
+ return m
40
+
41
+
42
+ def read_jsonl(path):
43
+ rows, bad = [], 0
44
+ with open(path, encoding="utf-8-sig") as f:
45
+ for line in f:
46
+ line = line.strip()
47
+ if not line:
48
+ continue
49
+ try:
50
+ rows.append(json.loads(line))
51
+ except json.JSONDecodeError:
52
+ bad += 1
53
+ if bad:
54
+ print(f" [warn] {bad} unparseable line(s) skipped in {os.path.basename(path)}")
55
+ return rows
56
+
57
+
58
+ def detect_format(rows):
59
+ if not rows:
60
+ return "empty"
61
+ r = rows[0]
62
+ if "choices" in r and "answer" in r and "question" in r:
63
+ return "mcq"
64
+ if "original_question" in r or "answer_1" in r or "correct_answer" in r:
65
+ return "raw"
66
+ return "unknown"
67
+
68
+
69
+ def describe(rows, label):
70
+ subj = collections.Counter(r.get("subject") or "?" for r in rows)
71
+ lvl = collections.Counter()
72
+ for r in rows:
73
+ md = r.get("metadata") or {}
74
+ lvl[str(md.get("exam_type") or r.get("level") or "?") + "/" +
75
+ str(md.get("grade") or r.get("grade") or "?")] += 1
76
+ nch = collections.Counter(len(r.get("choices") or []) for r in rows)
77
+ print(f" {label}: {len(rows)} rows")
78
+ print(f" subjects : {dict(subj.most_common(8))}")
79
+ print(f" level/grade: {dict(lvl.most_common(8))}")
80
+ print(f" n_choices: {dict(sorted(nch.items()))}")
81
+
82
+
83
+ def main():
84
+ ap = argparse.ArgumentParser()
85
+ ap.add_argument("--base", default="data/converted.jsonl",
86
+ help="existing converted file to append to ('' = start fresh)")
87
+ ap.add_argument("--new", nargs="+", required=True, help="newly extracted subject files")
88
+ ap.add_argument("--out", required=True)
89
+ ap.add_argument("--tmp_dir", default="", help="where to write per-file converted output "
90
+ "(default: alongside each input)")
91
+ ap.add_argument("--keep_latin", action="store_true")
92
+ ap.add_argument("--latin_allow", nargs="*", default=[])
93
+ ap.add_argument("--min_confidence", choices=["low", "medium", "high"], default="low")
94
+ args = ap.parse_args()
95
+ for stream in (sys.stdout, sys.stderr):
96
+ try:
97
+ stream.reconfigure(encoding="utf-8")
98
+ except Exception: # noqa: BLE001
99
+ pass
100
+
101
+ mcq = load_mcq_module()
102
+ merged, seen_ids = [], set()
103
+
104
+ if args.base:
105
+ if not os.path.exists(args.base):
106
+ sys.exit(f"--base {args.base} not found (pass --base '' to start fresh)")
107
+ base_rows = read_jsonl(args.base)
108
+ if detect_format(base_rows) != "mcq":
109
+ sys.exit(f"--base {args.base} is not in MCQ schema; pass the CONVERTED file")
110
+ print(f"[base] {args.base}")
111
+ describe(base_rows, "existing")
112
+ merged.extend(base_rows)
113
+
114
+ for path in args.new:
115
+ if not os.path.exists(path):
116
+ print(f"[skip] {path} not found")
117
+ continue
118
+ rows = read_jsonl(path)
119
+ fmt = detect_format(rows)
120
+ print(f"\n[new] {os.path.basename(path)} format={fmt}")
121
+ if fmt == "raw":
122
+ out_dir = args.tmp_dir or os.path.dirname(path) or "."
123
+ os.makedirs(out_dir, exist_ok=True)
124
+ stem = os.path.splitext(os.path.basename(path))[0]
125
+ conv = os.path.join(out_dir, f"{stem}_conv.jsonl")
126
+ rej = os.path.join(out_dir, f"{stem}_latin_rejects.jsonl")
127
+ mcq.cmd_convert(Namespace(inp=path, out=conv, min_confidence=args.min_confidence,
128
+ skip_needs_review=False, keep_shared_stem=False,
129
+ keep_latin=args.keep_latin, latin_allow=args.latin_allow,
130
+ rejects=rej))
131
+ rows = read_jsonl(conv)
132
+ elif fmt == "mcq":
133
+ print(" already in MCQ schema: passed through without conversion")
134
+ else:
135
+ print(f" [skip] unrecognised format; first keys: {sorted(rows[0])[:8] if rows else '-'}")
136
+ continue
137
+ describe(rows, "added")
138
+ merged.extend(rows)
139
+
140
+ # ids must be unique, or later stages (and any per-question analysis) get confused
141
+ out_rows, dup = [], 0
142
+ for i, r in enumerate(merged):
143
+ rid = r.get("id")
144
+ if rid is None or rid in seen_ids:
145
+ dup += 1
146
+ rid = f"{rid or 'row'}__{i}"
147
+ seen_ids.add(rid)
148
+ r["id"] = rid
149
+ out_rows.append(r)
150
+ if dup:
151
+ print(f"\n[ids] {dup} missing/duplicate id(s) were made unique")
152
+
153
+ os.makedirs(os.path.dirname(os.path.abspath(args.out)) or ".", exist_ok=True)
154
+ with open(args.out, "w", encoding="utf-8") as f:
155
+ for r in out_rows:
156
+ f.write(json.dumps(r, ensure_ascii=False) + "\n")
157
+
158
+ print(f"\n[out] {len(out_rows)} rows -> {args.out}")
159
+ describe(out_rows, "combined")
160
+ print("\nNext (run from the same folder):")
161
+ print(f" python mcq_dataset.py validate {args.out}")
162
+ print(f" python mcq_dataset.py clean {args.out} data/all_cleaned.jsonl --rejects data/all_clean_rejects.jsonl")
163
+ print(" python mcq_dataset.py dedupe data/all_cleaned.jsonl data/all_deduped.jsonl "
164
+ "--blocklist test_easy_block.jsonl --conflicts data/all_conflicts.jsonl")
165
+ print(" python mcq_dataset.py stats data/all_deduped.jsonl")
166
+ print(" python mcq_dataset.py split data/all_deduped.jsonl --train data/train2.jsonl "
167
+ "--val data/val2.jsonl --val_frac 0.1")
168
+ print(" python mcq_dataset.py build-sft data/train2.jsonl data/sft_train2.jsonl --shuffle_copies 2")
169
+ print(" python mcq_dataset.py build-sft data/val2.jsonl data/sft_val2.jsonl "
170
+ "--shuffle_copies 0 --strip_zwj_prob 0 --templates si_instruction")
171
+
172
+
173
+ if __name__ == "__main__":
174
+ main()
training/check_env.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Environment check for cpt_gemma4_sinhala.py (Unsloth + Gemma 4 E4B on an A40).
4
+
5
+ python check_env.py # quick checks (no model download)
6
+ python check_env.py --model # also loads Gemma 4 E4B, adds LoRA, runs 1 train step (~16 GB download)
7
+
8
+ Exit code 0 = everything passed.
9
+ """
10
+ import argparse
11
+ import importlib
12
+ import importlib.metadata as md
13
+ import subprocess
14
+ import sys
15
+
16
+ OK, BAD, WARN = "[ OK ]", "[FAIL]", "[WARN]"
17
+ failures = []
18
+
19
+
20
+ def report(tag, msg):
21
+ print(f"{tag} {msg}")
22
+ if tag == BAD:
23
+ failures.append(msg)
24
+
25
+
26
+ def ver(pkg):
27
+ try:
28
+ return md.version(pkg)
29
+ except md.PackageNotFoundError:
30
+ return None
31
+
32
+
33
+ def main():
34
+ ap = argparse.ArgumentParser()
35
+ ap.add_argument("--model", action="store_true", help="load model + 1 training step")
36
+ ap.add_argument("--model_name", default="unsloth/gemma-4-E4B")
37
+ args = ap.parse_args()
38
+
39
+ print(f"Python {sys.version.split()[0]}")
40
+
41
+ # 1. GPU / torch (import unsloth FIRST, as in the training script)
42
+ try:
43
+ import unsloth # noqa: F401
44
+ report(OK, f"unsloth imported (v{ver('unsloth')})")
45
+ except Exception as e: # noqa: BLE001
46
+ report(BAD, f"unsloth import failed: {type(e).__name__}: {e}")
47
+
48
+ try:
49
+ import torch
50
+ report(OK, f"torch {torch.__version__}, CUDA build {torch.version.cuda}")
51
+ if torch.cuda.is_available():
52
+ p = torch.cuda.get_device_properties(0)
53
+ report(OK, f"GPU: {p.name}, {p.total_memory / 1024**3:.1f} GB, bf16={torch.cuda.is_bf16_supported()}")
54
+ else:
55
+ report(BAD, "torch.cuda.is_available() is False (Unsloth needs a GPU)")
56
+ except Exception as e: # noqa: BLE001
57
+ report(BAD, f"torch import failed: {e}")
58
+
59
+ # 2. Pinned versions
60
+ transformers_v = ver("transformers")
61
+ if transformers_v == "5.5.0":
62
+ report(OK, "transformers 5.5.0")
63
+ else:
64
+ report(BAD, f"transformers is {transformers_v}, expected 5.5.0 "
65
+ "(re-run: pip install --no-deps transformers==5.5.0)")
66
+
67
+ tok_v = ver("tokenizers")
68
+ try:
69
+ from packaging.version import Version
70
+ good = tok_v and Version("0.22.0") <= Version(tok_v) <= Version("0.23.0")
71
+ except Exception: # noqa: BLE001
72
+ good = False
73
+ report(OK if good else BAD, f"tokenizers {tok_v} (need >=0.22.0,<=0.23.0)")
74
+
75
+ hub_v = ver("huggingface_hub")
76
+ try:
77
+ from packaging.version import Version
78
+ good = hub_v and Version("1.5.0") <= Version(hub_v) < Version("2.0")
79
+ except Exception: # noqa: BLE001
80
+ good = False
81
+ report(OK if good else BAD, f"huggingface_hub {hub_v} (need >=1.5.0,<2.0)")
82
+
83
+ # 3. Other packages the script imports
84
+ for pkg in ["trl", "peft", "datasets", "pandas", "accelerate", "bitsandbytes", "timm", "hf_transfer"]:
85
+ v = ver(pkg)
86
+ report(OK if v else BAD, f"{pkg} {v or 'NOT INSTALLED'}")
87
+
88
+ # 4. Imports the training script actually does
89
+ for stmt in ["from unsloth import FastModel",
90
+ "from trl import SFTConfig, SFTTrainer",
91
+ "from datasets import Dataset",
92
+ "from huggingface_hub import hf_hub_download"]:
93
+ try:
94
+ exec(stmt, {}) # noqa: S102
95
+ report(OK, stmt)
96
+ except Exception as e: # noqa: BLE001
97
+ report(BAD, f"{stmt} -> {type(e).__name__}: {e}")
98
+
99
+ # 5. Do the SFTConfig args used by the script exist in this TRL version?
100
+ try:
101
+ from trl import SFTConfig
102
+ import inspect
103
+ fields = set(inspect.signature(SFTConfig.__init__).parameters)
104
+ for a in ["max_length", "packing", "dataset_text_field", "bf16", "optim",
105
+ "eval_strategy", "warmup_ratio"]:
106
+ report(OK if a in fields else BAD, f"SFTConfig accepts '{a}'")
107
+ except Exception as e: # noqa: BLE001
108
+ report(WARN, f"could not inspect SFTConfig: {e}")
109
+
110
+ # 6. pip's own dependency check (informational: --no-deps installs can show conflicts)
111
+ out = subprocess.run([sys.executable, "-m", "pip", "check"], capture_output=True, text=True).stdout.strip()
112
+ if out and "No broken requirements" not in out:
113
+ print(f"{WARN} pip check reported:\n{out}\n (conflicts about transformers/tokenizers are often "
114
+ "harmless with --no-deps; only worry if the model test below fails)")
115
+ else:
116
+ report(OK, "pip check: no broken requirements")
117
+
118
+ # 7. Optional end-to-end test
119
+ if args.model:
120
+ try:
121
+ import torch
122
+ from unsloth import FastModel
123
+ from datasets import Dataset
124
+ from trl import SFTConfig, SFTTrainer
125
+
126
+ print(f"\nLoading {args.model_name} ...")
127
+ model, tok = FastModel.from_pretrained(
128
+ model_name=args.model_name, dtype=None, max_seq_length=512,
129
+ load_in_4bit=False, full_finetuning=False)
130
+ report(OK, "model + tokenizer loaded")
131
+
132
+ model = FastModel.get_peft_model(
133
+ model, finetune_vision_layers=False, finetune_language_layers=True,
134
+ finetune_attention_modules=True, finetune_mlp_modules=True,
135
+ r=8, lora_alpha=8, lora_dropout=0, bias="none", use_rslora=True, random_state=3407)
136
+ report(OK, "LoRA attached (use_rslora accepted)")
137
+
138
+ sin = "ශ්‍රී ලංකාව ඉන්දියන් සාගරයේ පිහිටි ද්වීපයකි."
139
+ ids = tok(sin, add_special_tokens=False)["input_ids"]
140
+ print(f" Sinhala sample -> {len(ids)} tokens for {len(sin)} chars")
141
+
142
+ ds = Dataset.from_dict({"text": [(sin + "\n") * 20] * 16})
143
+ cfg = SFTConfig(output_dir="/tmp/envcheck", dataset_text_field="text", max_length=512,
144
+ packing=True, per_device_train_batch_size=2, gradient_accumulation_steps=1,
145
+ max_steps=2, learning_rate=5e-5, bf16=True, optim="adamw_8bit",
146
+ logging_steps=1, save_strategy="no", report_to="none")
147
+ tr = SFTTrainer(model=model, tokenizer=tok, train_dataset=ds, args=cfg)
148
+ tr.train()
149
+ report(OK, f"2 training steps ran; peak VRAM {torch.cuda.max_memory_reserved() / 1024**3:.1f} GB")
150
+ except Exception as e: # noqa: BLE001
151
+ import traceback
152
+ traceback.print_exc()
153
+ report(BAD, f"model test failed: {type(e).__name__}: {e}")
154
+
155
+ print("\n" + ("ALL CHECKS PASSED" if not failures else f"{len(failures)} PROBLEM(S):"))
156
+ for f in failures:
157
+ print(" -", f)
158
+ sys.exit(1 if failures else 0)
159
+
160
+
161
+ if __name__ == "__main__":
162
+ main()
training/eval_sinhalammlu.py ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ SinhalaMMLU evaluation with the ORGANISERS' own prompt (copied from their util_prompt.py).
4
+
5
+ Two subcommands:
6
+
7
+ blocklist - convert the official TEST json files into the mcq_dataset.py schema, so they can be
8
+ used for DECONTAMINATION:
9
+ python eval_sinhalammlu.py blocklist --data /workspace/data/TEST/easy \
10
+ --out test_easy_block.jsonl
11
+ python mcq_dataset.py dedupe data/cleaned.jsonl data/deduped.jsonl \
12
+ --blocklist test_easy_block.jsonl --conflicts data/conflicts.jsonl
13
+ Any non-zero "overlap with blocklist" means your training data contained test
14
+ questions and they have now been removed. Re-run split/build-sft after this.
15
+
16
+ eval - score a model on those files. Reports BOTH:
17
+ loglik = pick the option whose answer-token has the highest log-probability
18
+ (this is how the model was trained and how we measured validation)
19
+ generate = greedy-decode after "පිළිතුර:" and read the first digit
20
+ (this is what the organisers' evaluate.py does)
21
+ If generate is much worse than loglik, the model knows the answer but is not
22
+ emitting a clean digit, which would cost real marks.
23
+
24
+ python eval_sinhalammlu.py eval --data /workspace/data/TEST/easy \
25
+ --model /workspace/outputs/mcq_sft_base/best_adapter \
26
+ --out_csv results_easy.csv
27
+
28
+ NOTE ON THE RULES: the shared task forbids "training, tuning or prompt design on test questions in
29
+ any form". Use this as a one-off read-only sanity check. Do NOT use it to choose a checkpoint, a
30
+ prompt or hyperparameters: use your own held-out val.jsonl for that.
31
+ """
32
+ import argparse
33
+ import csv
34
+ import glob
35
+ import json
36
+ import os
37
+ import re
38
+ import sys
39
+ import time
40
+ from collections import defaultdict
41
+
42
+ os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
43
+
44
+ # --- the organisers' prompt text, verbatim from util_prompt.py -------------------------------
45
+ SINHALA_INSTRUCTIONS = {
46
+ "easy": "පහත ප්‍රශ්නයට 1, 2, 3, 4 යන පිළිතුරුවලින් නිවැරදි හෝ ඉතාමත් ගැළපෙන පිළිතුර තෝරන්න.\n\n",
47
+ "medium": "පහත ප්‍රශ්නයට 1, 2, 3, 4 යන පිළිතුරුවලින් නිවැරදි හෝ ඉතාමත් ගැළපෙන පිළිතුර තෝරාගන්න.\n\n",
48
+ "hard": "පහත ප්‍රශ්නයට 1, 2, 3, 4, 5 යන පිළිතුරුවලින් නිවැරදි හෝ ඉතාමත් ගැළපෙන පිළිතුර තෝරාගන්න.\n\n",
49
+ }
50
+
51
+
52
+ def fit_numbers(instruction, n):
53
+ """The organisers hard-code "1, 2, 3, 4, 5" in the HARD instruction (the original SinhalaMMLU
54
+ hard split had five options). SinhalaMMLU 2.0 dropped one, so hard questions now have four;
55
+ the number run is rewritten to match the actual option count."""
56
+ return re.sub(r"1,\s*2,\s*3,\s*4(?:,\s*5)?",
57
+ ", ".join(str(i + 1) for i in range(n)), instruction, count=1)
58
+
59
+
60
+ def official_prompt(level, subject_original, question, choices, intro_type=True):
61
+ """Zero-shot Sinhala prompt, identical to generate_instruction_prompt(..., language='sinhala')."""
62
+ intro = f"මෙය {subject_original} විෂයයට අදාළ බහුවරණ ප්‍රශ්නයකි.\n" if intro_type else ""
63
+ body = "\n".join(f"{i + 1}. {str(c).strip()}" for i, c in enumerate(choices))
64
+ instr = fit_numbers(SINHALA_INSTRUCTIONS[level.lower()], len(choices))
65
+ return intro + instr + f"ප්‍රශ්නය: {question}\n{body}\nපිළිතුර:"
66
+
67
+
68
+ def load_items(data_dir, level_override=""):
69
+ """Read every *.json in data_dir (each a list of question dicts)."""
70
+ files = sorted(glob.glob(os.path.join(data_dir, "**", "*.json"), recursive=True))
71
+ if not files:
72
+ sys.exit(f"no .json files found under {data_dir}")
73
+ rows = []
74
+ for path in files:
75
+ try:
76
+ data = json.load(open(path, encoding="utf-8"))
77
+ except Exception as e: # noqa: BLE001
78
+ print(f"[warn] skipping {os.path.basename(path)}: {type(e).__name__}: {e}")
79
+ continue
80
+ if not isinstance(data, list):
81
+ print(f"[warn] skipping {os.path.basename(path)}: not a JSON list")
82
+ continue
83
+ for it in data:
84
+ md = it.get("metadata") or {}
85
+ level = level_override or md.get("difficulty") or "easy"
86
+ if level.lower() not in SINHALA_INSTRUCTIONS:
87
+ level = "easy"
88
+ ch = [c for c in (it.get("choices") or []) if str(c).strip()]
89
+ ans = it.get("answer")
90
+ if len(ch) < 2 or not isinstance(ans, int) or not (1 <= ans <= len(ch)):
91
+ continue
92
+ rows.append({"file": os.path.basename(path), "q_no": it.get("q_no"),
93
+ "subject": it.get("subject"), "category": it.get("category"),
94
+ "question": str(it.get("question") or "").strip(), "choices": ch,
95
+ "answer": ans, "level": level.lower(),
96
+ "subject_original": md.get("subject_original") or it.get("subject"),
97
+ "source": md.get("source")})
98
+ print(f"[data] {len(rows)} questions from {len(files)} file(s) in {data_dir}")
99
+ return rows
100
+
101
+
102
+ # ------------------------------------------------------------------ blocklist (no GPU needed)
103
+ def cmd_blocklist(a):
104
+ rows = load_items(a.data, a.level)
105
+ with open(a.out, "w", encoding="utf-8") as f:
106
+ for i, r in enumerate(rows):
107
+ f.write(json.dumps({"id": f"mmlu_{r['level']}_{r['file']}_{r['q_no']}",
108
+ "subject": r["subject"], "question": r["question"],
109
+ "choices": r["choices"], "answer": r["answer"],
110
+ "metadata": {"source": r["source"], "split": "official_test"}},
111
+ ensure_ascii=False) + "\n")
112
+ print(f"[out] {len(rows)} questions -> {a.out}")
113
+ print("Now decontaminate your TRAINING data (this is the compliant use of these files):")
114
+ print(f" python mcq_dataset.py dedupe data/cleaned.jsonl data/deduped.jsonl --blocklist {a.out}")
115
+ print("Any non-zero 'overlap with blocklist' = test questions were in your training set.")
116
+
117
+
118
+ # ------------------------------------------------------------------ eval (needs the GPU)
119
+ def common_prefix_len(x, y):
120
+ n = 0
121
+ for p, q in zip(x, y):
122
+ if p != q:
123
+ break
124
+ n += 1
125
+ return n
126
+
127
+
128
+ def score_loglik(model, tok, rows, prompts, max_len, batch_seqs):
129
+ """Total log-prob of each candidate answer token; returns a predicted 1-based option per row."""
130
+ import torch
131
+ dev = next(model.parameters()).device
132
+ pad = tok.pad_token_id if tok.pad_token_id is not None else 0
133
+ items, skipped = [], set()
134
+ for ri, (r, p) in enumerate(zip(rows, prompts)):
135
+ p_ids = tok(p)["input_ids"]
136
+ seqs = []
137
+ for ci in range(len(r["choices"])):
138
+ f = tok(p + f" {ci + 1}")["input_ids"]
139
+ seqs.append((f, max(1, common_prefix_len(p_ids, f))))
140
+ if max(len(s) for s, _ in seqs) > max_len:
141
+ skipped.add(ri)
142
+ continue
143
+ for ci, (s, k) in enumerate(seqs):
144
+ items.append((ri, ci, s, k))
145
+
146
+ scores = {}
147
+ with torch.no_grad():
148
+ for i in range(0, len(items), batch_seqs):
149
+ chunk = items[i:i + batch_seqs]
150
+ L = max(len(x[2]) for x in chunk)
151
+ ids = torch.full((len(chunk), L), pad, dtype=torch.long)
152
+ mask = torch.zeros((len(chunk), L), dtype=torch.long)
153
+ for b, (_, _, s, _) in enumerate(chunk):
154
+ ids[b, :len(s)] = torch.tensor(s)
155
+ mask[b, :len(s)] = 1
156
+ logits = model(input_ids=ids.to(dev), attention_mask=mask.to(dev)).logits
157
+ for b, (ri, ci, s, k) in enumerate(chunk):
158
+ lp = logits[b, k - 1:len(s) - 1].float().log_softmax(-1)
159
+ tgt = ids[b, k:len(s)].to(dev)
160
+ scores[(ri, ci)] = lp.gather(-1, tgt[:, None]).sum().item()
161
+ del logits
162
+
163
+ preds = []
164
+ for ri, r in enumerate(rows):
165
+ if ri in skipped:
166
+ preds.append(None)
167
+ continue
168
+ cs = [scores.get((ri, ci)) for ci in range(len(r["choices"]))]
169
+ preds.append(None if any(c is None for c in cs)
170
+ else 1 + max(range(len(cs)), key=lambda i: cs[i]))
171
+ return preds, len(skipped)
172
+
173
+
174
+ def score_generate(model, tok, prompts, max_new_tokens, batch_rows):
175
+ """Greedy-decode and read the first digit, the way the organisers' evaluate.py does."""
176
+ import torch
177
+ dev = next(model.parameters()).device
178
+ pad = tok.pad_token_id if tok.pad_token_id is not None else 0
179
+ preds, raw = [], []
180
+ with torch.no_grad():
181
+ for i in range(0, len(prompts), batch_rows):
182
+ batch = prompts[i:i + batch_rows]
183
+ enc = [tok(p)["input_ids"] for p in batch]
184
+ L = max(len(e) for e in enc)
185
+ ids = torch.full((len(enc), L), pad, dtype=torch.long)
186
+ mask = torch.zeros((len(enc), L), dtype=torch.long)
187
+ for b, e in enumerate(enc): # LEFT pad so generation continues correctly
188
+ ids[b, L - len(e):] = torch.tensor(e)
189
+ mask[b, L - len(e):] = 1
190
+ out = model.generate(input_ids=ids.to(dev), attention_mask=mask.to(dev),
191
+ max_new_tokens=max_new_tokens, do_sample=False,
192
+ pad_token_id=pad)
193
+ for b in range(len(enc)):
194
+ text = tok.decode(out[b, L:], skip_special_tokens=True)
195
+ raw.append(text)
196
+ m = re.search(r"[1-5]", text)
197
+ preds.append(int(m.group()) if m else None)
198
+ return preds, raw
199
+
200
+
201
+ def score_official(model, tok, prompts, n_choices, system="You must output the Answer",
202
+ batch_rows=8, no_chat=False):
203
+ """Exact replica of the organisers' predict_classification_causal_by_number (utils.py):
204
+ wrap in the chat template with a system message, ONE forward pass, take the logits at the final
205
+ position, and argmax over the token ids of the BARE digits '1'..'N' (no leading space).
206
+ This is what actually decides their score - there is no generation involved."""
207
+ import torch
208
+ dev = next(model.parameters()).device
209
+ pad = tok.pad_token_id if tok.pad_token_id is not None else 0
210
+
211
+ mode = {"how": "raw prompt (no chat template requested)"} if no_chat else {}
212
+
213
+ def wrap(instruction):
214
+ if no_chat:
215
+ return instruction
216
+ msgs = [{"role": "system", "content": system}, {"role": "user", "content": instruction}]
217
+ try:
218
+ out = tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
219
+ mode.setdefault("how", "chat template, system + user roles")
220
+ return out
221
+ except Exception:
222
+ pass
223
+ try:
224
+ # many Gemma templates reject a system role: fold it into the first user turn
225
+ msgs = [{"role": "user", "content": system + "\n\n" + instruction}]
226
+ out = tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
227
+ mode.setdefault("how", "chat template, system folded into the user turn")
228
+ return out
229
+ except Exception:
230
+ # NO chat template on this tokenizer at all (base Gemma 4 has none).
231
+ # The organisers' evaluate.py calls apply_chat_template unconditionally, so it would
232
+ # CRASH here too -> scoring the raw prompt is the closest thing we can measure.
233
+ mode.setdefault("how", "NO CHAT TEMPLATE on this tokenizer: scored the raw prompt. "
234
+ "The organisers' evaluate.py would fail on this model as-is.")
235
+ return instruction
236
+
237
+ texts = [wrap(p) for p in prompts]
238
+ # digit token ids, exactly as they compute them
239
+ dig = {k: tok.encode(str(k), add_special_tokens=False)[-1] for k in range(1, 6)}
240
+ preds = []
241
+ with torch.no_grad():
242
+ for i in range(0, len(texts), batch_rows):
243
+ batch = texts[i:i + batch_rows]
244
+ enc = [tok(t)["input_ids"] for t in batch] # add_special_tokens default, as they do
245
+ L = max(len(e) for e in enc)
246
+ ids = torch.full((len(enc), L), pad, dtype=torch.long)
247
+ mask = torch.zeros((len(enc), L), dtype=torch.long)
248
+ for b, e in enumerate(enc): # LEFT pad: last column is the last real token
249
+ ids[b, L - len(e):] = torch.tensor(e)
250
+ mask[b, L - len(e):] = 1
251
+ logits = model(input_ids=ids.to(dev), attention_mask=mask.to(dev)).logits[:, -1, :].float()
252
+ for b in range(len(enc)):
253
+ nc = n_choices[i + b]
254
+ cand = [dig[k] for k in range(1, nc + 1)]
255
+ row = logits[b, torch.tensor(cand, device=logits.device)]
256
+ preds.append(1 + int(row.argmax().item()))
257
+ del logits
258
+ print(f"[official] prompt construction: {mode.get('how', 'unknown')}")
259
+ return preds, texts[0]
260
+
261
+
262
+ def report(rows, preds, label):
263
+ n = correct = unparsed = 0
264
+ by_cat, by_subj = defaultdict(lambda: [0, 0]), defaultdict(lambda: [0, 0])
265
+ for r, p in zip(rows, preds):
266
+ if p is None:
267
+ unparsed += 1
268
+ continue
269
+ n += 1
270
+ ok = int(p == r["answer"])
271
+ correct += ok
272
+ by_cat[r["category"] or "?"][0] += ok
273
+ by_cat[r["category"] or "?"][1] += 1
274
+ by_subj[r["subject"] or "?"][0] += ok
275
+ by_subj[r["subject"] or "?"][1] += 1
276
+ print(f"\n=== {label} ===")
277
+ print(f"accuracy {correct / max(1, n):.4f} ({correct}/{n}); no prediction: {unparsed}")
278
+ print(" by category:")
279
+ for k, (c, t) in sorted(by_cat.items()):
280
+ print(f" {k:<18} {c / t:.4f} ({c}/{t})")
281
+ print(" by subject:")
282
+ for k, (c, t) in sorted(by_subj.items()):
283
+ if t >= 20:
284
+ print(f" {k:<18} {c / t:.4f} ({c}/{t})")
285
+ return {"accuracy": correct / max(1, n), "n": n, "no_prediction": unparsed,
286
+ "by_category": {k: v[0] / v[1] for k, v in by_cat.items()},
287
+ "by_subject": {k: v[0] / v[1] for k, v in by_subj.items()}}
288
+
289
+
290
+ def cmd_eval(a):
291
+ rows = load_items(a.data, a.level)
292
+ if a.limit:
293
+ rows = rows[:a.limit]
294
+ prompts = [official_prompt(r["level"], r["subject_original"], r["question"], r["choices"],
295
+ intro_type=not a.no_intro) for r in rows]
296
+ print("\n--- prompt actually sent to the model (first question) ---")
297
+ print(prompts[0])
298
+ print("--- end of prompt ---\n")
299
+ if a.dry_run:
300
+ return
301
+
302
+ from unsloth import FastModel
303
+ print(f"[model] loading {a.model}")
304
+ model, processor = FastModel.from_pretrained(model_name=a.model, dtype=None,
305
+ max_seq_length=a.max_seq_length,
306
+ load_in_4bit=False, full_finetuning=False)
307
+ tok = getattr(processor, "tokenizer", processor)
308
+ if tok.pad_token is None:
309
+ tok.pad_token = tok.eos_token
310
+ model.eval()
311
+
312
+ out = {"model": a.model, "data": a.data, "n_questions": len(rows)}
313
+ t0 = time.time()
314
+ ll_preds, skipped = score_loglik(model, tok, rows, prompts, a.max_seq_length, a.eval_batch)
315
+ print(f"[loglik] {time.time() - t0:.0f}s; skipped (too long) {skipped}")
316
+ out["loglik"] = report(rows, ll_preds, "loglik (how the model was trained/validated)")
317
+
318
+ if not a.skip_official:
319
+ t0 = time.time()
320
+ off_preds, shown = score_official(model, tok, prompts, [len(r["choices"]) for r in rows],
321
+ system=a.system, batch_rows=a.gen_batch, no_chat=a.no_chat)
322
+ print(f"[official] {time.time() - t0:.0f}s")
323
+ print("--- prompt as the organisers' harness builds it (chat template applied) ---")
324
+ print(shown[:900] + ("..." if len(shown) > 900 else ""))
325
+ print("--- end ---")
326
+ out["official"] = report(rows, off_preds, "OFFICIAL (bare-digit logits at last position)")
327
+
328
+ gen_preds, gen_raw = [None] * len(rows), [""] * len(rows)
329
+ if not a.skip_generate:
330
+ t0 = time.time()
331
+ gen_preds, gen_raw = score_generate(model, tok, prompts, a.max_new_tokens, a.gen_batch)
332
+ print(f"[generate] {time.time() - t0:.0f}s")
333
+ out["generate"] = report(rows, gen_preds, "generate (how the organisers score)")
334
+ bad = [t for t, p in zip(gen_raw, gen_preds) if p is None][:5]
335
+ if bad:
336
+ print(f" examples with no digit in the output: {bad}")
337
+
338
+ if a.out_csv:
339
+ # columns match the organisers' get_accuracy_all.py so their aggregator can read this
340
+ with open(a.out_csv, "w", encoding="utf-8", newline="") as f:
341
+ w = csv.writer(f)
342
+ w.writerow(["q_no", "subject", "category", "question", "choices", "answer",
343
+ "generated_text", "model_predicted_answer", "loglik_pred", "raw_output"])
344
+ for r, lp, gp, g in zip(rows, ll_preds, gen_preds, gen_raw):
345
+ w.writerow([r["q_no"], r["subject"], r["category"], r["question"],
346
+ json.dumps(r["choices"], ensure_ascii=False), r["answer"],
347
+ gp if gp is not None else "", gp if gp is not None else "", lp, g])
348
+ print(f"[out] per-question results -> {a.out_csv}")
349
+ if a.out_json:
350
+ json.dump(out, open(a.out_json, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
351
+ print(f"[out] summary -> {a.out_json}")
352
+ print("\nREMINDER: these are official test questions. Do not use this score to choose a "
353
+ "checkpoint, prompt or hyperparameter; the task rules forbid tuning on test questions.")
354
+
355
+
356
+ def main():
357
+ ap = argparse.ArgumentParser()
358
+ sub = ap.add_subparsers(dest="cmd", required=True)
359
+
360
+ p = sub.add_parser("blocklist", help="convert official TEST json -> jsonl for decontamination")
361
+ p.add_argument("--data", required=True)
362
+ p.add_argument("--out", required=True)
363
+ p.add_argument("--level", default="")
364
+ p.set_defaults(f=cmd_blocklist)
365
+
366
+ p = sub.add_parser("eval", help="score a model with the organisers' prompt")
367
+ p.add_argument("--data", required=True)
368
+ p.add_argument("--model", required=True, help="adapter dir, merged dir or HF repo")
369
+ p.add_argument("--level", default="", help="force easy/medium/hard (default: read from metadata)")
370
+ p.add_argument("--no_intro", action="store_true", help="drop the subject intro line (intro_type=False)")
371
+ p.add_argument("--max_seq_length", type=int, default=1024)
372
+ p.add_argument("--eval_batch", type=int, default=16)
373
+ p.add_argument("--gen_batch", type=int, default=8)
374
+ p.add_argument("--max_new_tokens", type=int, default=5)
375
+ p.add_argument("--skip_generate", action="store_true")
376
+ p.add_argument("--skip_official", action="store_true")
377
+ p.add_argument("--no_chat", action="store_true", help="official scoring WITHOUT the chat template")
378
+ p.add_argument("--system", default="You must output the Answer",
379
+ help="system message the organisers' evaluate.py uses")
380
+ p.add_argument("--limit", type=int, default=0)
381
+ p.add_argument("--dry_run", action="store_true", help="print the prompt and exit (no model load)")
382
+ p.add_argument("--out_csv", default="")
383
+ p.add_argument("--out_json", default="")
384
+ p.set_defaults(f=cmd_eval)
385
+
386
+ a = ap.parse_args()
387
+ for stream in (sys.stdout, sys.stderr):
388
+ try:
389
+ stream.reconfigure(encoding="utf-8")
390
+ except Exception: # noqa: BLE001
391
+ pass
392
+ a.f(a)
393
+
394
+
395
+ if __name__ == "__main__":
396
+ main()
training/make_predictions.py ADDED
@@ -0,0 +1,487 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Produce the shared-task submission: predictions.zip containing predictions.jsonl.
4
+
5
+ Required line format (one JSON object per question):
6
+ {"q_no":1,"subject":"Arts","category":"humanities","level":"easy","answer":"2"}
7
+
8
+ `answer` is a STRING holding the 1-based option number.
9
+
10
+ python make_predictions.py \
11
+ --data /workspace/data/TEST \
12
+ --model /workspace/outputs/mcq_sft_base_v2/best_adapter \
13
+ --out predictions.jsonl --zip predictions.zip
14
+
15
+ --data may be either a single folder of *.json files, or a root containing easy/ medium/ hard/
16
+ subfolders; the level comes from metadata.difficulty, falling back to the folder name.
17
+
18
+ Scoring: for every option, score the joint tokenisation of prompt+" N" and take the highest total
19
+ log-probability ("loglik"). This is deterministic, always yields a valid 1..N answer, and measured
20
+ highest on TEST/easy. Because the task is submitted as PREDICTIONS, we are free to choose the
21
+ decoding method - we are not restricted to the organisers' bare-digit logit comparison.
22
+
23
+ Reproducibility: the organisers re-run submitted code on their own hardware and only verified runs
24
+ enter the final ranking. Nothing here samples; seeds are fixed and generation is not used. Keep this
25
+ file, the adapter/model reference and the prompt builder together in the submitted code.
26
+ """
27
+ import argparse
28
+ import glob
29
+ import json
30
+ import os
31
+ import re
32
+ import random
33
+ import sys
34
+ import time
35
+ import zipfile
36
+
37
+ os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
38
+
39
+ # --- the organisers' prompt, verbatim from their util_prompt.py -------------------------------
40
+ SINHALA_INSTRUCTIONS = {
41
+ "easy": "පහත ප්‍රශ්නයට 1, 2, 3, 4 යන පිළිතුරුවලින් නිවැරදි හෝ ඉතාමත් ගැළපෙන පිළිතුර තෝරන්න.\n\n",
42
+ "medium": "පහත ප්‍රශ්නයට 1, 2, 3, 4 යන පිළිතුරුවලින් නිවැරදි හෝ ඉතාමත් ගැළපෙන පිළිතුර තෝරාගන්න.\n\n",
43
+ "hard": "පහත ප්‍රශ්නයට 1, 2, 3, 4, 5 යන පිළිතුරුවලින් නිවැරදි හෝ ඉතාමත් ගැළපෙන පිළිතුර තෝරාගන්න.\n\n",
44
+ }
45
+ ENGLISH_INSTRUCTIONS = {
46
+ "easy": "Choose the correct or most appropriate answer from 1, 2, 3, 4 for the question below.\n\n",
47
+ "medium": "Choose the correct or most appropriate answer from 1, 2, 3, 4 for the question below.\n\n",
48
+ "hard": "Select the correct answer from 1, 2, 3, 4, 5 for the given question.\n\n",
49
+ }
50
+ LEVELS = ("easy", "medium", "hard")
51
+
52
+ # Prompt variants to ensemble over. Each is a real configuration the organisers' harness supports
53
+ # (sinhala/english x intro true/false), plus option rotations to cancel answer-position bias.
54
+ # "rot" rotates the DISPLAYED option order; predictions are mapped back to the original indices.
55
+ VARIANTS = {
56
+ "si_intro": {"lang": "si", "intro": True, "rot": 0},
57
+ "si_nointro": {"lang": "si", "intro": False, "rot": 0},
58
+ "en_intro": {"lang": "en", "intro": True, "rot": 0},
59
+ "si_rot1": {"lang": "si", "intro": True, "rot": 1},
60
+ "si_rot2": {"lang": "si", "intro": True, "rot": 2},
61
+ "si_rot3": {"lang": "si", "intro": True, "rot": 3},
62
+ "en_nointro": {"lang": "en", "intro": False, "rot": 0},
63
+ "en_rot1": {"lang": "en", "intro": True, "rot": 1},
64
+ "si_nointro_r2": {"lang": "si", "intro": False, "rot": 2},
65
+ }
66
+ DEFAULT_ENSEMBLE = ["si_intro", "si_nointro", "en_intro", "si_rot1", "si_rot2"]
67
+
68
+
69
+ def fit_numbers(instruction, n):
70
+ """The organisers hard-code "1, 2, 3, 4, 5" in the HARD instruction because the original
71
+ SinhalaMMLU hard split had five options. SinhalaMMLU 2.0 dropped one incorrect option, so hard
72
+ questions now have four. Telling the model to choose from five options when four are shown is
73
+ strictly worse, so the number run is rewritten to match the actual count."""
74
+ return re.sub(r"1,\s*2,\s*3,\s*4(?:,\s*5)?",
75
+ ", ".join(str(i + 1) for i in range(n)), instruction, count=1)
76
+
77
+
78
+ def official_prompt(level, subject_original, question, choices, intro_type=True):
79
+ intro = f"මෙය {subject_original} විෂයයට අදාළ බහුවරණ ප්‍රශ්නයකි.\n" if intro_type else ""
80
+ body = "\n".join(f"{i + 1}. {str(c).strip()}" for i, c in enumerate(choices))
81
+ instr = fit_numbers(SINHALA_INSTRUCTIONS[level], len(choices))
82
+ return intro + instr + f"ප්‍රශ්නය: {question}\n{body}\nපිළිතුර:"
83
+
84
+
85
+ def english_prompt(level, subject, question, choices, intro_type=True):
86
+ """The organisers' prompt with language="english" (their english_intro uses the ENGLISH subject)."""
87
+ intro = (f"This is a multiple choice question related to the subject {subject}. "
88
+ "Given in Sinhala Language.\n") if intro_type else ""
89
+ body = "\n".join(f"{i + 1}. {str(c).strip()}" for i, c in enumerate(choices))
90
+ instr = fit_numbers(ENGLISH_INSTRUCTIONS[level], len(choices))
91
+ return intro + instr + f"Question: {question}\n{body}\nAnswer:"
92
+
93
+
94
+ def rotate(choices, rot):
95
+ """Displayed order; displayed position j holds original option (j + rot) % n."""
96
+ n = len(choices)
97
+ r = rot % n if n else 0
98
+ return choices[r:] + choices[:r], r
99
+
100
+
101
+ def variant_prompt(row, spec):
102
+ shown, r = rotate(row["choices"], spec["rot"])
103
+ if spec["lang"] == "en":
104
+ p = english_prompt(row["level"], row["subject"], row["question"], shown, spec["intro"])
105
+ else:
106
+ p = official_prompt(row["level"], row["subject_original"], row["question"], shown, spec["intro"])
107
+ return p, r, len(shown)
108
+
109
+
110
+ def content_free_prompt(row, spec, filler="N/A"):
111
+ """Same template and option count, but the question and every option replaced by a neutral
112
+ filler. Scoring the digits here measures the model's a-priori preference among option POSITIONS,
113
+ independent of content (contextual calibration / PMI-DC). Depends only on
114
+ (variant, level, subject, n_choices), so it is cached across questions."""
115
+ stub = dict(row)
116
+ stub["question"] = filler
117
+ stub["choices"] = [filler] * len(row["choices"])
118
+ return variant_prompt(stub, spec)
119
+
120
+
121
+ def level_of(item, path, override=""):
122
+ """metadata.difficulty, else a top-level 'level'/'difficulty' field, else the file name or any
123
+ path component containing easy/medium/hard, else 'easy'."""
124
+ if override:
125
+ return override
126
+ md = item.get("metadata") or {}
127
+ for cand in (md.get("difficulty"), md.get("level"), item.get("level"), item.get("difficulty")):
128
+ d = str(cand or "").strip().lower()
129
+ if d in LEVELS:
130
+ return d
131
+ low = path.replace("\\", "/").lower()
132
+ # substring match so "hard.json", "test_hard.jsonl" and ".../hard/..." all work
133
+ hits = [lv for lv in LEVELS if lv in low]
134
+ if len(hits) == 1:
135
+ return hits[0]
136
+ if hits: # e.g. ".../easy/medium_extra.json": trust the last one
137
+ return max(hits, key=low.rfind)
138
+ return "easy"
139
+
140
+
141
+ def read_any(path):
142
+ """A JSON array, a JSONL file, or a dict wrapping the list under a common key."""
143
+ raw = open(path, encoding="utf-8-sig").read().strip()
144
+ if not raw:
145
+ return []
146
+ if raw[0] == "[":
147
+ return json.loads(raw)
148
+ if raw[0] == "{":
149
+ try:
150
+ obj = json.loads(raw)
151
+ except json.JSONDecodeError:
152
+ obj = None
153
+ if isinstance(obj, dict):
154
+ for k in ("data", "questions", "items", "rows", "test"):
155
+ if isinstance(obj.get(k), list):
156
+ return obj[k]
157
+ return [obj]
158
+ # not one object -> treat as JSONL
159
+ out = []
160
+ for line in raw.splitlines():
161
+ line = line.strip()
162
+ if line:
163
+ out.append(json.loads(line))
164
+ return out
165
+
166
+
167
+ def load_items(data_dir, level_override=""):
168
+ if os.path.isfile(data_dir):
169
+ files = [data_dir]
170
+ else:
171
+ files = sorted(set(glob.glob(os.path.join(data_dir, "**", "*.json"), recursive=True))
172
+ | set(glob.glob(os.path.join(data_dir, "**", "*.jsonl"), recursive=True)))
173
+ if not files:
174
+ sys.exit(f"no .json/.jsonl files found under {data_dir}")
175
+ rows, skipped, per_file = [], 0, []
176
+ for path in files:
177
+ try:
178
+ data = read_any(path)
179
+ except Exception as e: # noqa: BLE001
180
+ print(f"[warn] {os.path.basename(path)}: {type(e).__name__}: {e}")
181
+ continue
182
+ if not isinstance(data, list) or not data:
183
+ print(f"[warn] {os.path.basename(path)}: no question list found")
184
+ continue
185
+ before = len(rows)
186
+ for it in data:
187
+ md = it.get("metadata") or {}
188
+ ch = [c for c in (it.get("choices") or []) if str(c).strip()]
189
+ if len(ch) < 2:
190
+ skipped += 1 # never drop silently: these still need a prediction line
191
+ ans = it.get("answer")
192
+ rows.append({
193
+ "file": os.path.basename(path),
194
+ "gold": ans if isinstance(ans, int) and 1 <= ans <= max(2, len(ch)) else None,
195
+ "q_no": it.get("q_no"),
196
+ "subject": it.get("subject"),
197
+ "category": it.get("category"),
198
+ "level": level_of(it, path, level_override),
199
+ "question": str(it.get("question") or "").strip(),
200
+ "choices": ch,
201
+ "subject_original": md.get("subject_original") or it.get("subject"),
202
+ })
203
+ lv = {r["level"] for r in rows[before:]}
204
+ per_file.append((os.path.basename(path), len(rows) - before, sorted(lv)))
205
+ by_level = {}
206
+ for r in rows:
207
+ by_level[r["level"]] = by_level.get(r["level"], 0) + 1
208
+ print(f"[data] {len(rows)} questions from {len(files)} file(s); by level: {by_level}")
209
+ for name, n, lv in per_file:
210
+ print(f" {name:<44} {n:>6} questions level -> {','.join(lv)}")
211
+ missing = [lv for lv in LEVELS if lv not in by_level]
212
+ if missing:
213
+ print(f"[warn] no questions detected for: {missing}. If that is wrong, the level could not be "
214
+ "read from metadata.difficulty or the file name - check the table above.")
215
+ if skipped:
216
+ print(f"[warn] {skipped} question(s) have fewer than 2 usable options; they still get a line")
217
+ return rows
218
+
219
+
220
+ def common_prefix_len(a, b):
221
+ n = 0
222
+ for x, y in zip(a, b):
223
+ if x != y:
224
+ break
225
+ n += 1
226
+ return n
227
+
228
+
229
+ def predict(model, tok, rows, max_len, batch_seqs, variants, calibrate=False):
230
+ """Deterministic prompt ensemble. For every (question, variant) the options are scored, converted
231
+ to a log-softmax over the DISPLAYED positions (so variants with different prompt lengths are on a
232
+ common scale), mapped back to the original option order, and summed across variants.
233
+ No sampling, no generation: the same inputs always give the same predictions."""
234
+ import math
235
+ import torch
236
+ model.eval()
237
+ dev = next(model.parameters()).device
238
+ pad = tok.pad_token_id if tok.pad_token_id is not None else 0
239
+
240
+ items, meta = [], {} # (ri, vi, shown_pos, ids, answer_start) ; (ri,vi)->(rot,n)
241
+ for ri, r in enumerate(rows):
242
+ if len(r["choices"]) < 2:
243
+ continue
244
+ for vi, vname in enumerate(variants):
245
+ prompt, rot, n = variant_prompt(r, VARIANTS[vname])
246
+ meta[(ri, vi)] = (rot, n)
247
+ p_ids = tok(prompt)["input_ids"]
248
+ for j in range(n):
249
+ f = tok(prompt + f" {j + 1}")["input_ids"]
250
+ if len(f) > max_len:
251
+ f = f[-max_len:]
252
+ items.append((ri, vi, j, f, max(1, common_prefix_len(p_ids, f))))
253
+
254
+ # ---- calibration priors: one short prompt per (variant, level, subject, n_choices)
255
+ prior_items, prior_key = [], {}
256
+ if calibrate:
257
+ for ri, r in enumerate(rows):
258
+ if len(r["choices"]) < 2:
259
+ continue
260
+ for vi, vname in enumerate(variants):
261
+ key = (vi, r["level"], r["subject_original"], len(r["choices"]))
262
+ prior_key[(ri, vi)] = key
263
+ if key in [k for _, k, _, _, _ in prior_items]:
264
+ continue
265
+ cf, rot, n = content_free_prompt(r, VARIANTS[vname])
266
+ p_ids = tok(cf)["input_ids"]
267
+ for j in range(n):
268
+ f = tok(cf + f" {j + 1}")["input_ids"]
269
+ prior_items.append((len(prior_items), key, j, f, max(1, common_prefix_len(p_ids, f))))
270
+ # de-duplicate: keep the first occurrence of each (key, j)
271
+ seen, uniq = set(), []
272
+ for _, key, j, f, k in prior_items:
273
+ if (key, j) in seen:
274
+ continue
275
+ seen.add((key, j))
276
+ uniq.append((key, j, f, k))
277
+ prior_items = uniq
278
+ print(f"[calibrate] {len(prior_items)} content-free candidates "
279
+ f"({len({k for k, _, _, _ in prior_items})} distinct prompts)")
280
+
281
+ print(f"[predict] {len(rows)} questions x {len(variants)} variant(s) = {len(items)} candidates")
282
+ scores, t0 = {}, time.time()
283
+ with torch.no_grad():
284
+ for i in range(0, len(items), batch_seqs):
285
+ chunk = items[i:i + batch_seqs]
286
+ L = max(len(x[3]) for x in chunk)
287
+ ids = torch.full((len(chunk), L), pad, dtype=torch.long)
288
+ mask = torch.zeros((len(chunk), L), dtype=torch.long)
289
+ for b, (_, _, _, sq, _) in enumerate(chunk):
290
+ ids[b, :len(sq)] = torch.tensor(sq)
291
+ mask[b, :len(sq)] = 1
292
+ logits = model(input_ids=ids.to(dev), attention_mask=mask.to(dev)).logits
293
+ for b, (ri, vi, j, sq, k) in enumerate(chunk):
294
+ lp = logits[b, k - 1:len(sq) - 1].float().log_softmax(-1)
295
+ tgt = ids[b, k:len(sq)].to(dev)
296
+ scores[(ri, vi, j)] = lp.gather(-1, tgt[:, None]).sum().item()
297
+ del logits
298
+ if (i // max(1, batch_seqs)) % 100 == 0:
299
+ done = min(i + batch_seqs, len(items))
300
+ print(f" scored {done}/{len(items)} ({time.time() - t0:.0f}s)", flush=True)
301
+
302
+ priors = {}
303
+ if calibrate and prior_items:
304
+ with torch.no_grad():
305
+ for i in range(0, len(prior_items), batch_seqs):
306
+ chunk = prior_items[i:i + batch_seqs]
307
+ L = max(len(x[2]) for x in chunk)
308
+ ids = torch.full((len(chunk), L), pad, dtype=torch.long)
309
+ mask = torch.zeros((len(chunk), L), dtype=torch.long)
310
+ for b, (_, _, sq, _) in enumerate(chunk):
311
+ ids[b, :len(sq)] = torch.tensor(sq)
312
+ mask[b, :len(sq)] = 1
313
+ logits = model(input_ids=ids.to(dev), attention_mask=mask.to(dev)).logits
314
+ for b, (key, j, sq, k) in enumerate(chunk):
315
+ lp = logits[b, k - 1:len(sq) - 1].float().log_softmax(-1)
316
+ tgt = ids[b, k:len(sq)].to(dev)
317
+ priors[(key, j)] = lp.gather(-1, tgt[:, None]).sum().item()
318
+ del logits
319
+ print(f"[calibrate] priors computed in {time.time() - t0:.0f}s")
320
+
321
+ preds, fallback, agree = [], 0, 0
322
+ for ri, r in enumerate(rows):
323
+ n = len(r["choices"])
324
+ if n < 2:
325
+ preds.append(1)
326
+ fallback += 1
327
+ continue
328
+ total, used, per_variant = [0.0] * n, 0, []
329
+ for vi in range(len(variants)):
330
+ if (ri, vi) not in meta:
331
+ continue
332
+ rot, nv = meta[(ri, vi)]
333
+ raw = [scores.get((ri, vi, j)) for j in range(nv)]
334
+ if any(x is None for x in raw):
335
+ continue
336
+ m = max(raw) # log-softmax over displayed positions
337
+ z = m + math.log(sum(math.exp(x - m) for x in raw))
338
+ adj = [raw[j] - z for j in range(nv)]
339
+ if calibrate:
340
+ key = prior_key.get((ri, vi))
341
+ pr = [priors.get((key, j)) for j in range(nv)]
342
+ if all(x is not None for x in pr):
343
+ pm = max(pr)
344
+ pz = pm + math.log(sum(math.exp(x - pm) for x in pr))
345
+ adj = [adj[j] - (pr[j] - pz) for j in range(nv)] # subtract the position prior
346
+ best_j = max(range(nv), key=lambda j: adj[j])
347
+ per_variant.append(1 + (best_j + rot) % n)
348
+ for j in range(nv):
349
+ total[(j + rot) % n] += adj[j] # displayed j == original (j+rot)%n
350
+ used += 1
351
+ if used == 0:
352
+ preds.append(1)
353
+ fallback += 1
354
+ continue
355
+ preds.append(1 + max(range(n), key=lambda i: total[i]))
356
+ if len(set(per_variant)) == 1:
357
+ agree += 1
358
+
359
+ if fallback:
360
+ print(f"[warn] {fallback} question(s) fell back to answer 1")
361
+ if len(variants) > 1:
362
+ print(f"[predict] all variants agreed on {agree}/{len(rows)} ({agree / max(1, len(rows)):.1%}) "
363
+ "- the rest are where the ensemble changes the answer")
364
+ print(f"[predict] done in {time.time() - t0:.0f}s")
365
+ return preds, None
366
+
367
+
368
+ def main():
369
+ ap = argparse.ArgumentParser()
370
+ ap.add_argument("--data", required=True, help="folder of test *.json files, or a root with easy/medium/hard")
371
+ ap.add_argument("--model", required=True, help="adapter dir, merged dir, or HF repo")
372
+ ap.add_argument("--out", default="predictions.jsonl")
373
+ ap.add_argument("--zip", default="predictions.zip", help="'' to skip zipping")
374
+ ap.add_argument("--level", default="", choices=["", *LEVELS], help="force the level for every item")
375
+ ap.add_argument("--max_seq_length", type=int, default=1024)
376
+ ap.add_argument("--eval_batch", type=int, default=16)
377
+ ap.add_argument("--calibrate", action="store_true",
378
+ help="contextual calibration: measure the model's a-priori preference among "
379
+ "option POSITIONS on a content-free prompt and subtract it. ~50 extra "
380
+ "forward passes in total; standard fix for log-likelihood MCQ position bias.")
381
+ ap.add_argument("--score", action="store_true",
382
+ help="if the input carries gold 'answer' values (e.g. your val3.jsonl), report "
383
+ "accuracy as well. Use this to compare variant sets on HELD-OUT data "
384
+ "instead of spending Codabench submissions.")
385
+ ap.add_argument("--ensemble", nargs="*", default=None,
386
+ help=f"prompt variants to average over (default: {' '.join(DEFAULT_ENSEMBLE)}). "
387
+ f"Choices: {' '.join(VARIANTS)}. Pass 'si_intro' alone for single-prompt scoring.")
388
+ ap.add_argument("--seed", type=int, default=3407)
389
+ ap.add_argument("--dry_run", action="store_true", help="show the prompt and exit, no model load")
390
+ args = ap.parse_args()
391
+ for stream in (sys.stdout, sys.stderr):
392
+ try:
393
+ stream.reconfigure(encoding="utf-8")
394
+ except Exception: # noqa: BLE001
395
+ pass
396
+
397
+ random.seed(args.seed)
398
+ variants = args.ensemble if args.ensemble else list(DEFAULT_ENSEMBLE)
399
+ unknown = [v for v in variants if v not in VARIANTS]
400
+ if unknown:
401
+ sys.exit(f"unknown variant(s) {unknown}; choose from {list(VARIANTS)}")
402
+ print(f"[ensemble] {len(variants)} variant(s): {variants}")
403
+ rows = load_items(args.data, args.level)
404
+
405
+ if args.dry_run:
406
+ r = next((x for x in rows if len(x["choices"]) >= 2), None)
407
+ if r:
408
+ for v in variants:
409
+ prompt, rot, _ = variant_prompt(r, VARIANTS[v])
410
+ print(f"\n--- variant {v} (rot={rot}) ---")
411
+ print(prompt)
412
+ print("--- end ---")
413
+ return
414
+
415
+ import torch
416
+ torch.manual_seed(args.seed)
417
+ from unsloth import FastModel
418
+ print(f"[model] loading {args.model}")
419
+ model, processor = FastModel.from_pretrained(model_name=args.model, dtype=None,
420
+ max_seq_length=args.max_seq_length,
421
+ load_in_4bit=False, full_finetuning=False)
422
+ tok = getattr(processor, "tokenizer", processor)
423
+ if tok.pad_token is None:
424
+ tok.pad_token = tok.eos_token
425
+
426
+ preds, _ = predict(model, tok, rows, args.max_seq_length, args.eval_batch, variants,
427
+ calibrate=args.calibrate)
428
+
429
+ with open(args.out, "w", encoding="utf-8") as f:
430
+ for r, p in zip(rows, preds):
431
+ f.write(json.dumps({"q_no": r["q_no"], "subject": r["subject"],
432
+ "category": r["category"], "level": r["level"],
433
+ "answer": str(p)}, ensure_ascii=False) + "\n")
434
+ print(f"[out] {len(rows)} lines -> {args.out}")
435
+
436
+ # self-checks: the submission is rejected or mis-scored if any of these are wrong
437
+ lines = [json.loads(l) for l in open(args.out, encoding="utf-8") if l.strip()]
438
+ assert len(lines) == len(rows), "line count does not match the question count"
439
+ bad_keys = [i for i, d in enumerate(lines)
440
+ if set(d) != {"q_no", "subject", "category", "level", "answer"}]
441
+ bad_ans = [i for i, d in enumerate(lines)
442
+ if not (isinstance(d["answer"], str) and d["answer"] in {"1", "2", "3", "4", "5"})]
443
+ bad_lvl = [i for i, d in enumerate(lines) if d["level"] not in LEVELS]
444
+ for name, bad in [("wrong key set", bad_keys), ("answer not a '1'..'5' string", bad_ans),
445
+ ("level not easy/medium/hard", bad_lvl)]:
446
+ print(f"[check] {name}: {len(bad)}" + (f" e.g. line {bad[0] + 1}" if bad else " OK"))
447
+ dist = {}
448
+ for d in lines:
449
+ dist[d["answer"]] = dist.get(d["answer"], 0) + 1
450
+ print(f"[check] answer distribution: {dict(sorted(dist.items()))}")
451
+ dup = len(lines) - len({(d["level"], d["subject"], d["q_no"]) for d in lines})
452
+ print(f"[check] duplicate (level, subject, q_no) keys: {dup}")
453
+ if bad_keys or bad_ans or bad_lvl:
454
+ sys.exit("fix the problems above before submitting")
455
+
456
+ if args.score:
457
+ import collections
458
+ have = [(r, p) for r, p in zip(rows, preds) if r["gold"] is not None]
459
+ if not have:
460
+ print("[score] the input has no gold 'answer' field, nothing to score")
461
+ else:
462
+ n = sum(1 for _ in have)
463
+ c = sum(int(p == r["gold"]) for r, p in have)
464
+ print(f"\n[score] accuracy {c / n:.4f} ({c}/{n}) variants: {variants}")
465
+ for key, label in (("level", "level"), ("subject", "subject")):
466
+ agg = collections.defaultdict(lambda: [0, 0])
467
+ for r, p in have:
468
+ k = r.get(key) or "?"
469
+ agg[k][0] += int(p == r["gold"])
470
+ agg[k][1] += 1
471
+ print(f" by {label}:")
472
+ for k, (cc, tt) in sorted(agg.items(), key=lambda x: -x[1][1]):
473
+ if tt >= 20 or label == "level":
474
+ print(f" {str(k):<34} {cc / tt:.4f} ({cc}/{tt})")
475
+
476
+ if args.zip:
477
+ with zipfile.ZipFile(args.zip, "w", zipfile.ZIP_DEFLATED) as z:
478
+ z.write(args.out, arcname="predictions.jsonl") # must sit at the archive root
479
+ with zipfile.ZipFile(args.zip) as z:
480
+ print(f"[zip] {args.zip} contains: {z.namelist()}")
481
+ print("\nFirst two lines:")
482
+ for l in open(args.out, encoding="utf-8").readlines()[:2]:
483
+ print(" " + l.rstrip())
484
+
485
+
486
+ if __name__ == "__main__":
487
+ main()
training/mcq_dataset.py ADDED
@@ -0,0 +1,639 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Toolkit for building a Sinhala multiple-choice fine-tuning dataset (SinhalaMMLU-style).
4
+
5
+ Record schema (one JSON object per line, UTF-8). Mirrors the test file; `answer` is 1-based:
6
+ {"id": "ol2019_sci_p1_q07", "subject": "science", "category": "stem",
7
+ "question": "...", "choices": ["...", "...", "...", "..."], "answer": 2,
8
+ "metadata": {"source": "<paper id or URL>", "exam_type": "OL", "year": 2019, "grade": 11,
9
+ "difficulty": "medium", "extraction": "text_pdf|ocr|synthetic|translated"}}
10
+
11
+ Commands (run in this order):
12
+ python mcq_dataset.py example example.jsonl # writes 2 dummy records to see the schema
13
+ python mcq_dataset.py validate raw.jsonl # report problems, changes nothing
14
+ python mcq_dataset.py clean raw.jsonl clean.jsonl --rejects rejects.jsonl
15
+ python mcq_dataset.py dedupe clean.jsonl dedup.jsonl --blocklist public_test.json # also decontaminates
16
+ python mcq_dataset.py split dedup.jsonl --train train.jsonl --val val.jsonl --val_frac 0.1
17
+ python mcq_dataset.py stats train.jsonl
18
+ python mcq_dataset.py build-sft train.jsonl sft_train.jsonl --shuffle_copies 1 --strip_zwj_prob 0.5
19
+ python mcq_dataset.py build-sft val.jsonl sft_val.jsonl --shuffle_copies 0 --strip_zwj_prob 0 --templates si_instruction
20
+
21
+ The --blocklist file is ONLY used to find and drop overlapping questions. It is never copied into the output.
22
+ """
23
+ import argparse
24
+ import collections
25
+ import hashlib
26
+ import json
27
+ import random
28
+ import re
29
+ import sys
30
+ import unicodedata
31
+
32
+ ZWJ = "‍"
33
+
34
+ # options that refer to other options: never reorder these
35
+ POSITIONAL = [r"ඉහත", r"සියල්ල", r"කිසිවක්", r"[1-5]\s*(සහ|හා|and|&)\s*[1-5]", r"\ball of the above\b", r"\bnone of\b"]
36
+ FIGURE = [r"රූපය", r"රූප සටහන", r"රූපයේ", r"රූපවල", r"ප්රස්තාර", r"සිතියම", r"\bfigure\b", r"\bdiagram\b", r"\bgraph\b"]
37
+
38
+ SI_INSTR = "පහත බහුවරණ ප්‍රශ්නයට නිවැරදි පිළිතුරේ අංකය ලියන්න."
39
+ EN_INSTR = "Answer the following multiple-choice question. Reply with only the number of the correct option."
40
+
41
+
42
+ # ------------------------------------------------------------------ io / helpers
43
+ def load(path):
44
+ with open(path, encoding="utf-8") as f:
45
+ txt = f.read().strip()
46
+ if not txt:
47
+ return []
48
+ if txt[0] == "[":
49
+ return json.loads(txt)
50
+ return [json.loads(l) for l in txt.splitlines() if l.strip()]
51
+
52
+
53
+ def dump(path, rows):
54
+ with open(path, "w", encoding="utf-8") as f:
55
+ for r in rows:
56
+ f.write(json.dumps(r, ensure_ascii=False) + "\n")
57
+
58
+
59
+ def getpath(d, path, default=None):
60
+ for k in path.split("."):
61
+ if not isinstance(d, dict) or k not in d:
62
+ return default
63
+ d = d[k]
64
+ return d
65
+
66
+
67
+ def strip_zwj(s):
68
+ return s.replace(ZWJ, "")
69
+
70
+
71
+ def norm_key(s):
72
+ """Comparison key: letters/marks/digits only, no ZWJ, no spaces/punctuation, lower-case."""
73
+ s = unicodedata.normalize("NFC", s).lower()
74
+ return "".join(ch for ch in s if unicodedata.category(ch)[0] in "LMN")
75
+
76
+
77
+ def clean_text(s):
78
+ s = unicodedata.normalize("NFC", str(s))
79
+ s = s.replace("\\r\\n", " ").replace("\\n", " ").replace("\\r", " ").replace("\\t", " ") # literal escapes
80
+ # U+200C (ZWNJ) is a legacy-font-converter artifact after word-final virama; real test text has none
81
+ s = s.replace("​", "").replace("‌", "").replace("", "").replace("�", "")
82
+ s = "".join(ch if (ch in "\n\t" or unicodedata.category(ch) != "Cc") else " " for ch in s)
83
+ return re.sub(r"\s+", " ", s).strip()
84
+
85
+
86
+ def strip_numbering(choices):
87
+ """Remove '(1) ', '2. ', '3) ' prefixes, but only if ALL choices carry the matching sequential prefix."""
88
+ pats = [re.compile(rf"^\s*[\(\[]?{i + 1}[\)\]\.]\s*") for i in range(len(choices))]
89
+ if all(p.match(c) for p, c in zip(pats, choices)):
90
+ return [p.sub("", c, count=1) for p, c in zip(pats, choices)]
91
+ return choices
92
+
93
+
94
+ def garbage_ratio(s):
95
+ if not s:
96
+ return 0.0
97
+ bad = sum(1 for ch in s if "" <= ch <= "" or ch == "�" or "À" <= ch <= "ÿ")
98
+ return bad / len(s)
99
+
100
+
101
+ def sinhala_ratio(s):
102
+ letters = [ch for ch in s if unicodedata.category(ch).startswith("L")]
103
+ if not letters:
104
+ return 1.0
105
+ return sum(1 for ch in letters if "඀" <= ch <= "෿") / len(letters)
106
+
107
+
108
+ def any_match(patterns, text):
109
+ t = strip_zwj(text)
110
+ return any(re.search(p, t, flags=re.I) for p in patterns)
111
+
112
+
113
+ def problems_of(r):
114
+ """Hard errors (record unusable) and soft warnings."""
115
+ errs, warns = [], []
116
+ q, ch, a = r.get("question"), r.get("choices"), r.get("answer")
117
+ if not isinstance(q, str) or len(q.strip()) < 5:
118
+ errs.append("question missing/too short")
119
+ if not isinstance(ch, list) or not (2 <= len(ch) <= 6) or not all(isinstance(c, str) and c.strip() for c in ch):
120
+ errs.append("choices must be 2-6 non-empty strings")
121
+ elif len({norm_key(c) for c in ch}) < len(ch):
122
+ errs.append("duplicate choices")
123
+ if not isinstance(a, int) or isinstance(a, bool):
124
+ errs.append("answer must be an integer (1-based)")
125
+ elif isinstance(ch, list) and not (1 <= a <= len(ch)):
126
+ errs.append("answer out of range (must be 1-based)")
127
+ if not r.get("subject"):
128
+ warns.append("no subject")
129
+ if not getpath(r, "metadata.source"):
130
+ warns.append("no metadata.source (needed for splitting and for removing overlaps later)")
131
+ blob = (q or "") + " " + " ".join(ch if isinstance(ch, list) else [])
132
+ if "\\n" in blob:
133
+ warns.append("literal backslash-n in text")
134
+ if re.search(r"<[a-zA-Z/][^>]*>", blob):
135
+ warns.append("HTML tags in text")
136
+ if garbage_ratio(blob) > 0.05:
137
+ errs.append("garbled text (legacy font / OCR garbage)")
138
+ if sinhala_ratio(blob) < 0.25:
139
+ warns.append("little Sinhala text (English medium or garbled?)")
140
+ if isinstance(q, str) and any_match(FIGURE, q):
141
+ warns.append("refers to a figure/diagram")
142
+ return errs, warns
143
+
144
+
145
+ # ------------------------------------------------------------------ commands
146
+ def cmd_example(a):
147
+ rows = [
148
+ {"id": "demo_1", "subject": "science", "category": "stem", "question": "උදාහරණ ප්‍රශ්නය එක කුමක්ද?",
149
+ "choices": ["පිළිතුර එක", "පිළිතුර දෙක", "පිළිතුර තුන", "පිළිතුර හතර"], "answer": 2,
150
+ "metadata": {"source": "demo_paper_A", "exam_type": "OL", "year": 2019, "grade": 11,
151
+ "difficulty": "medium", "extraction": "text_pdf"}},
152
+ {"id": "demo_2", "subject": "science", "category": "stem", "question": "උදාහරණ ප්‍රශ්නය දෙක කුමක්ද?",
153
+ "choices": ["එක", "දෙක", "තුන", "ඉහත සියල්ලම"], "answer": 4,
154
+ "metadata": {"source": "demo_paper_B", "exam_type": "Scholarship", "year": 2018, "grade": 5,
155
+ "difficulty": "easy", "extraction": "text_pdf"}},
156
+ ]
157
+ dump(a.out, rows)
158
+ print(f"wrote {a.out}")
159
+
160
+
161
+ JUNK_CHARS = set("-—–~|।*!'\"“”‘’.•_:;,")
162
+ LATIN_OK = {"UN", "UNO", "UNESCO", "NATO", "SAARC", "ASEAN", "GDP", "IT", "ICT", "TV", "DNA", "HIV", "AIDS", "WHO",
163
+ "CD", "DVD", "BC", "AD", "pH", "CO2", "H2O", "SMS", "PDF", "USA", "UK", "EU", "AI"}
164
+
165
+
166
+ LATIN_OK |= {"kg", "cm", "mm", "km", "ml", "ms", "cm3", "m2", "m3", "mg", "Hz", "kHz", "MHz", "GHz", "KB", "MB", "GB", "TB",
167
+ "bps", "Kbps", "Mbps", "Ctrl", "Alt", "Shift", "Enter", "Esc", "Tab", "Del", "Delete", "Home", "End", "Page",
168
+ "key", "Key", "ASCII", "USB", "RAM", "ROM", "CPU", "ALU", "BIOS", "OS", "LAN", "WAN", "IP", "MSB", "LSB", "BCD",
169
+ "www", "http", "https", "HTML", "URL", "Data", "Duplex", "control", "AND", "OR", "NOT", "XOR", "NAND", "NOR",
170
+ "II", "III", "IV", "VI", "VII", "VIII", "IX", "Excel", "Word", "Windows", "Access", "PowerPoint", "Internet",
171
+ "Email", "Wi", "Fi", "pH", "DC", "AC", "LED", "LCD", "PIN", "ATM", "VAT", "PAYE", "CEO", "NGO", "SMS", "GPS"}
172
+
173
+
174
+ def chem_like(t):
175
+ """Chemical formula such as HCl, NaOH, CaCO, CO, Mg: element symbols with <=2-digit counts, >=2 distinct chars
176
+ (so 'OO' and 'S888' are NOT accepted)."""
177
+ return bool(re.fullmatch(r"(?:[A-Z][a-z]?\d{0,2}){1,6}", t)) and len(set(t)) > 1
178
+
179
+
180
+ LATIN_OK |= {"of", "to", "in", "and", "or", "the", "gov", "lk", "com", "moe", "edu", "org", "net", "www", "mol", "aq", "ppm", "kgm",
181
+ "Web", "Hub", "Mem", "Sri", "TT", "tt", "CC", "Cc", "Tt", "ii", "iii", "iv", "vi", "vii", "viii", "ix"}
182
+
183
+
184
+ def looks_like_ocr_garbage(t):
185
+ """Real English words / acronyms / formulas are fine inside Sinhala exam text (ICT, science, URLs...).
186
+ Garbage looks different: tokens containing digits ('S888'), very short stray tokens ('wo', 'Qed'),
187
+ or letter soup with no vowel / a long consonant run."""
188
+ if t in LATIN_OK or chem_like(t):
189
+ return False
190
+ if re.search(r"\d", t):
191
+ return True
192
+ if len(t) <= 3:
193
+ return True
194
+ if not re.search(r"[AEIOUaeiouYy]", t):
195
+ return True
196
+ if re.search(r"[^AEIOUaeiouYy]{5,}", t):
197
+ return True
198
+ return False
199
+
200
+
201
+ def latin_garbage_tokens(s):
202
+ """Latin tokens (2+ chars) that look like OCR garbage (e.g. 'S888', 'Qed', 'OO', 'wo')."""
203
+ return [t for t in re.findall(r"[A-Za-z][A-Za-z0-9]*", s) if len(t) >= 2 and looks_like_ocr_garbage(t)]
204
+
205
+
206
+ def trim_tail(s):
207
+ """Remove OCR debris from the END of a question/option: lone punctuation, '|', '।', short quote-led tokens,
208
+ and Latin garbage tokens such as 'Qed,'."""
209
+ toks = s.split(" ")
210
+ while toks:
211
+ t = toks[-1]
212
+ junk_only = t == "" or all(ch in JUNK_CHARS for ch in t)
213
+ quote_led = t[:1] in "“”‘’'\"|।*~" and len(t) <= 3
214
+ latin_junk = bool(latin_garbage_tokens(t)) and len(re.sub(r"[A-Za-z0-9]", "", t)) <= 1
215
+ if junk_only or quote_led or latin_junk:
216
+ toks.pop()
217
+ else:
218
+ break
219
+ return " ".join(toks)
220
+
221
+
222
+ SHARED_STEM = [r"ප්‍රශ්නවලට", r"ප්රශ්නවලට", r"ඇසුරෙන්", r"ඉහත\s*(ප්‍රකාශ|ප්රකාශ|වගුව|සටහන|කොටස|ඡේදය)", r"පහත\s*(ඡේදය|කොටස|ප්‍රකාශ|ප්රකාශ)"]
223
+ CONF = {"low": 0, "medium": 1, "high": 2}
224
+
225
+
226
+ def cmd_convert(a):
227
+ """Convert the PDF-extraction format (answer_1..answer_5 + correct_answer letter) to the MCQ schema."""
228
+ rows, out, rej = load(a.inp), [], collections.Counter()
229
+ seen = collections.Counter()
230
+ latin_tok, latin_rows = collections.Counter(), []
231
+ for r in rows:
232
+ conf = str(r.get("extraction_confidence") or "unknown").lower()
233
+ seen[("confidence", conf)] += 1
234
+ seen[("needs_review", bool(r.get("needs_review")))] += 1
235
+ qt = r.get("question_type")
236
+ if qt not in (None, "", "mcq"):
237
+ rej[f"question_type={qt}"] += 1
238
+ continue
239
+ if CONF.get(conf, 0) < CONF[a.min_confidence]:
240
+ rej["below min_confidence"] += 1
241
+ continue
242
+ if a.skip_needs_review and r.get("needs_review"):
243
+ rej["needs_review"] += 1
244
+ continue
245
+ # the training label must exist: rows with a null/empty correct_answer are unusable
246
+ if not str(r.get("correct_answer") or "").strip():
247
+ rej["no correct_answer (null)"] += 1
248
+ continue
249
+ q = clean_text(r.get("original_question") or "")
250
+ # OCR junk after the final question mark, e.g. "...ද? ී i “"
251
+ if "?" in q:
252
+ head, tail = q.rsplit("?", 1)
253
+ if len(tail.strip()) <= 8:
254
+ q = head + "?"
255
+ q = trim_tail(q)
256
+ raw_choices, spill = [], False
257
+ filled = [i for i in range(1, 6) if r.get(f"answer_{i}") is not None and str(r.get(f"answer_{i}")).strip()]
258
+ last_i = filled[-1] if filled else 0
259
+ for i in range(1, 6):
260
+ v = r.get(f"answer_{i}")
261
+ if v is None or not str(v).strip():
262
+ raw_choices.append(None)
263
+ continue
264
+ lines = [ln.strip() for ln in str(v).strip().split("\n") if ln.strip()]
265
+ keep = [lines[0]]
266
+ for ln in lines[1:]:
267
+ if i < last_i:
268
+ # another option follows, so nothing can have leaked in: a newline is a wrapped line
269
+ keep.append(ln)
270
+ else:
271
+ # last option: continuation lines are short; next-question text is long / a question / numbered
272
+ looks_like_leak = (len(ln) > 80 or "?" in ln or re.match(r"^\(?\d+[\.\)]", ln)
273
+ or any_match(SHARED_STEM, ln))
274
+ if looks_like_leak:
275
+ spill = True
276
+ break
277
+ keep.append(ln)
278
+ first = clean_text(" ".join(keep))
279
+ first = trim_tail(first).rstrip(" ,;:")
280
+ raw_choices.append(first or None)
281
+ # options must be contiguous from 1
282
+ n = 0
283
+ while n < 5 and raw_choices[n]:
284
+ n += 1
285
+ if any(raw_choices[n:]):
286
+ rej["non-contiguous options"] += 1
287
+ continue
288
+ choices = raw_choices[:n]
289
+ if n < 3:
290
+ rej["fewer than 3 options"] += 1
291
+ continue
292
+ ca = str(r.get("correct_answer") or "").strip().upper()
293
+ if ca in "ABCDE" and ca:
294
+ ans = "ABCDE".index(ca) + 1
295
+ elif ca.isdigit():
296
+ ans = int(ca)
297
+ else:
298
+ rej["missing/invalid correct_answer"] += 1
299
+ continue
300
+ if not (1 <= ans <= n):
301
+ rej["correct_answer outside the options"] += 1
302
+ continue
303
+ if len(q) < 8:
304
+ rej["question too short"] += 1
305
+ continue
306
+ if any_match(SHARED_STEM, q) and not a.keep_shared_stem:
307
+ rej["depends on a shared stem/passage"] += 1
308
+ continue
309
+ if max(len(c) for c in choices) > 250:
310
+ rej["option too long (extraction spill)"] += 1
311
+ continue
312
+ if not a.keep_latin:
313
+ allow = {t for t in (a.latin_allow or [])}
314
+ bad = [t for t in latin_garbage_tokens(q + " " + " ".join(choices)) if t not in allow]
315
+ if bad:
316
+ rej["OCR garbage (Latin letters/digits inside Sinhala text)"] += 1
317
+ latin_tok.update(bad)
318
+ latin_rows.append({"id": r.get("id"), "subject": r.get("subject"), "tokens": bad,
319
+ "question": q, "choices": choices})
320
+ continue
321
+ out.append({
322
+ "id": r.get("id"), "subject": r.get("subject"), "question": q, "choices": choices, "answer": ans,
323
+ "metadata": {"source": r.get("source_pdf"), "page": r.get("source_page"), "year": r.get("year"),
324
+ "exam_type": r.get("level"), "medium": r.get("medium"),
325
+ "extraction_confidence": conf, "needs_review": bool(r.get("needs_review")),
326
+ "option_spill_fixed": spill}})
327
+ dump(a.out, out)
328
+ print(f"read {len(rows)} -> kept {len(out)} ({len(out) / max(1, len(rows)):.0%}) -> {a.out}")
329
+ for (k, v), c in sorted(seen.items(), key=lambda kv: str(kv[0])):
330
+ print(f" input {k}={v}: {c}")
331
+ for k, v in rej.most_common():
332
+ print(f" dropped: {k}: {v}")
333
+ if latin_rows:
334
+ print(" most common Latin tokens in the rows dropped as 'OCR garbage' (legit terms like CPU/Excel/NaCl "
335
+ "should be allowed with --latin_allow):")
336
+ print(" ", ", ".join(f"{t}:{c}" for t, c in latin_tok.most_common(40)))
337
+ if a.rejects:
338
+ dump(a.rejects, latin_rows)
339
+ print(f" dropped Latin rows saved to {a.rejects}")
340
+ if out and all(x["metadata"]["needs_review"] for x in out):
341
+ print("WARNING: every kept record is flagged needs_review. Label noise from extraction is the main risk: "
342
+ "hand-check ~100 random records (python mcq_dataset.py sample ...) before training.")
343
+
344
+
345
+ def cmd_sample(a):
346
+ rows = load(a.inp)
347
+ random.Random(a.seed).shuffle(rows)
348
+ lines = []
349
+ for r in rows[:a.n]:
350
+ md = r.get("metadata") or {}
351
+ lines.append("-" * 60 + f" {r.get('id')} [{md.get('extraction_confidence', '?')}]")
352
+ lines.append("Q: " + r["question"])
353
+ for i, c in enumerate(r["choices"], 1):
354
+ lines.append(f" {'*' if i == r['answer'] else ' '} {i}. {c}")
355
+ text = "\n".join(lines) + "\n"
356
+ if a.out:
357
+ # write the file directly as UTF-8 (a PowerShell '>' redirect re-encodes and garbles Sinhala)
358
+ with open(a.out, "w", encoding="utf-8-sig") as f:
359
+ f.write(text)
360
+ print(f"wrote {min(a.n, len(rows))} records -> {a.out}")
361
+ else:
362
+ print(text, end="")
363
+
364
+
365
+ def cmd_validate(a):
366
+ rows = load(a.inp)
367
+ ec, wc = collections.Counter(), collections.Counter()
368
+ bad = 0
369
+ for r in rows:
370
+ errs, warns = problems_of(r)
371
+ bad += bool(errs)
372
+ ec.update(errs)
373
+ wc.update(warns)
374
+ print(f"{len(rows)} records, {bad} with hard errors ({bad / max(1, len(rows)):.1%})")
375
+ for k, v in ec.most_common():
376
+ print(f" ERROR {v:6d} {k}")
377
+ for k, v in wc.most_common():
378
+ print(f" warn {v:6d} {k}")
379
+ zw = sum(ZWJ in (r.get("question", "") + "".join(r.get("choices", []) or [])) for r in rows)
380
+ print(f"records containing ZWJ (U+200D): {zw} ({zw / max(1, len(rows)):.1%})")
381
+
382
+
383
+ def cmd_clean(a):
384
+ out, rej = [], []
385
+ for r in load(a.inp):
386
+ r = dict(r)
387
+ if isinstance(r.get("answer"), str) and r["answer"].strip().isdigit():
388
+ r["answer"] = int(r["answer"].strip())
389
+ if isinstance(r.get("question"), str):
390
+ r["question"] = clean_text(r["question"])
391
+ if isinstance(r.get("choices"), list):
392
+ ch = [clean_text(c) for c in r["choices"] if isinstance(c, str)]
393
+ r["choices"] = strip_numbering(ch)
394
+ errs, warns = problems_of(r)
395
+ if "refers to a figure/diagram" in warns and not a.keep_figures:
396
+ errs.append("needs a figure/diagram")
397
+ if errs:
398
+ rej.append({**r, "_reject": errs})
399
+ else:
400
+ out.append(r)
401
+ dump(a.out, out)
402
+ if a.rejects:
403
+ dump(a.rejects, rej)
404
+ print(f"kept {len(out)}, rejected {len(rej)} -> {a.out}")
405
+ for k, v in collections.Counter(e for x in rej for e in x["_reject"]).most_common():
406
+ print(f" rejected for: {k}: {v}")
407
+
408
+
409
+ def shingles(key, n=8):
410
+ return {hash(key[i:i + n]) for i in range(max(1, len(key) - n + 1))}
411
+
412
+
413
+ def rec_key(r):
414
+ return norm_key(r.get("question", "") + " " + " ".join(r.get("choices", [])))
415
+
416
+
417
+ class Index:
418
+ def __init__(self):
419
+ self.post = collections.defaultdict(list)
420
+ self.n = 0
421
+
422
+ def add(self, sh):
423
+ for h in sh:
424
+ self.post[h].append(self.n)
425
+ self.n += 1
426
+
427
+ def best_containment(self, sh):
428
+ if not sh:
429
+ return 0.0
430
+ cnt = collections.Counter()
431
+ for h in sh:
432
+ for d in self.post.get(h, ()):
433
+ cnt[d] += 1
434
+ return max(cnt.values()) / len(sh) if cnt else 0.0
435
+
436
+
437
+ def cmd_dedupe(a):
438
+ rows = load(a.inp)
439
+ # 1) same question text: identical answer -> keep first, conflicting answers -> drop all (likely label noise)
440
+ groups = collections.defaultdict(list)
441
+ for r in rows:
442
+ groups[norm_key(r["question"])].append(r)
443
+ kept, conflicts = [], []
444
+ for k, g in groups.items():
445
+ ans = {norm_key(x["choices"][x["answer"] - 1]) for x in g}
446
+ if len(ans) > 1:
447
+ conflicts += g
448
+ else:
449
+ kept.append(g[0])
450
+ dups = len(rows) - len(kept) - len(conflicts)
451
+ # 2) near-duplicates inside the set
452
+ idx, uniq, near = Index(), [], 0
453
+ for r in kept:
454
+ sh = shingles(rec_key(r))
455
+ if idx.best_containment(sh) >= a.near_threshold:
456
+ near += 1
457
+ continue
458
+ idx.add(sh)
459
+ uniq.append(r)
460
+ # 3) overlap with blocklist (e.g. public test questions): drop, never copied
461
+ overlap = 0
462
+ if a.blocklist:
463
+ bfull, bq = Index(), Index()
464
+ for p in a.blocklist:
465
+ for b in load(p):
466
+ if isinstance(b, dict) and b.get("question"):
467
+ bfull.add(shingles(norm_key(b["question"] + " " + " ".join(b.get("choices", []) or []))))
468
+ bq.add(shingles(norm_key(b["question"])))
469
+ final = []
470
+ for r in uniq:
471
+ qk = norm_key(r["question"])
472
+ # drop if the whole item matches, OR the question text alone matches (robust to differently formatted options)
473
+ hit = bfull.best_containment(shingles(rec_key(r))) >= a.block_threshold or \
474
+ (len(qk) >= 25 and bq.best_containment(shingles(qk)) >= 0.8)
475
+ if hit:
476
+ overlap += 1
477
+ else:
478
+ final.append(r)
479
+ uniq = final
480
+ dump(a.out, uniq)
481
+ if conflicts and a.conflicts:
482
+ dump(a.conflicts, conflicts)
483
+ print(f"in {len(rows)} -> out {len(uniq)} | exact dup {dups} | answer conflicts {len(conflicts)} | "
484
+ f"near-dup {near} | overlap with blocklist {overlap}")
485
+
486
+
487
+ def cmd_split(a):
488
+ rows = load(a.inp)
489
+ tr, va = [], []
490
+ groups = set()
491
+ for r in rows:
492
+ g = str(getpath(r, a.group_key) or r.get("id", "")).strip() or "none"
493
+ groups.add(g)
494
+ h = int(hashlib.sha1(f"{a.seed}:{g}".encode()).hexdigest()[:8], 16) / 2 ** 32
495
+ (va if h < a.val_frac else tr).append(r)
496
+ dump(a.train, tr)
497
+ dump(a.val, va)
498
+ print(f"{len(groups)} groups ({a.group_key}) -> train {len(tr)}, val {len(va)}")
499
+ if len(groups) < 10:
500
+ print("WARNING: few groups; the split may be lopsided. Add metadata.source to every record.")
501
+
502
+
503
+ def cmd_stats(a):
504
+ rows = load(a.inp)
505
+ print(f"{len(rows)} records")
506
+ for title, f in [("subject", lambda r: r.get("subject")), ("category", lambda r: r.get("category")),
507
+ ("difficulty", lambda r: getpath(r, "metadata.difficulty")),
508
+ ("exam_type", lambda r: getpath(r, "metadata.exam_type")),
509
+ ("year", lambda r: getpath(r, "metadata.year")),
510
+ ("n_choices", lambda r: len(r.get("choices", []))),
511
+ ("answer position", lambda r: r.get("answer")),
512
+ ("extraction", lambda r: getpath(r, "metadata.extraction_confidence") or getpath(r, "metadata.extraction"))]:
513
+ c = collections.Counter(f(r) for r in rows)
514
+ print(f"\n{title}:")
515
+ for k, v in sorted(c.items(), key=lambda kv: -kv[1])[:20]:
516
+ print(f" {str(k):28s} {v:6d} {v / len(rows):6.1%}")
517
+
518
+
519
+ def render(r, tpl, rng):
520
+ ch = r["choices"]
521
+ letters = "ABCDEF"
522
+ subj = r.get("subject") or ""
523
+ if tpl == "bare":
524
+ body = r["question"] + "\n" + "\n".join(f"{i + 1}. {c}" for i, c in enumerate(ch)) + "\nපිළිතුර:"
525
+ return body, f" {r['answer']}"
526
+ if tpl == "si_instruction":
527
+ head = SI_INSTR + "\n\n" + (f"විෂය: {subj}\n" if subj else "")
528
+ body = head + f"ප්‍රශ්නය: {r['question']}\n" + "\n".join(f"{i + 1}. {c}" for i, c in enumerate(ch)) + "\nපිළිතුර:"
529
+ return body, f" {r['answer']}"
530
+ if tpl == "en_instruction":
531
+ head = EN_INSTR + "\n\n" + (f"Subject: {subj}\n" if subj else "")
532
+ body = head + f"Question: {r['question']}\n" + "\n".join(f"{i + 1}. {c}" for i, c in enumerate(ch)) + "\nAnswer:"
533
+ return body, f" {r['answer']}"
534
+ if tpl == "letters":
535
+ body = f"Question: {r['question']}\n" + "\n".join(f"{letters[i]}. {c}" for i, c in enumerate(ch)) + "\nAnswer:"
536
+ return body, f" {letters[r['answer'] - 1]}"
537
+ raise ValueError(tpl)
538
+
539
+
540
+ TEMPLATE_WEIGHTS = {"si_instruction": 0.35, "bare": 0.25, "en_instruction": 0.25, "letters": 0.15}
541
+
542
+
543
+ def shuffle_safe(r):
544
+ return not any(any_match(POSITIONAL, c) for c in r["choices"])
545
+
546
+
547
+ def cmd_build_sft(a):
548
+ rng = random.Random(a.seed)
549
+ names = a.templates or list(TEMPLATE_WEIGHTS)
550
+ weights = [TEMPLATE_WEIGHTS[n] for n in names]
551
+ rows, out, n_shuf_skip = load(a.inp), [], 0
552
+ for r in rows:
553
+ variants = [r]
554
+ if a.shuffle_copies > 0:
555
+ if shuffle_safe(r):
556
+ for _ in range(a.shuffle_copies):
557
+ perm = list(range(len(r["choices"])))
558
+ rng.shuffle(perm)
559
+ new_choices = [r["choices"][i] for i in perm]
560
+ new_ans = perm.index(r["answer"] - 1) + 1
561
+ variants.append({**r, "choices": new_choices, "answer": new_ans})
562
+ else:
563
+ n_shuf_skip += 1
564
+ for v in variants:
565
+ v = dict(v)
566
+ if rng.random() < a.strip_zwj_prob:
567
+ v["question"] = strip_zwj(v["question"])
568
+ v["choices"] = [strip_zwj(c) for c in v["choices"]]
569
+ v["subject"] = strip_zwj(v.get("subject") or "")
570
+ tpl = rng.choices(names, weights)[0]
571
+ prompt, completion = render(v, tpl, rng)
572
+ if a.format == "chat":
573
+ rec = {"prompt": [{"role": "user", "content": prompt}],
574
+ "completion": [{"role": "assistant", "content": completion.strip()}]}
575
+ else:
576
+ lab = "ABCDEF" if tpl == "letters" else "123456"
577
+ rec = {"prompt": prompt, "completion": completion,
578
+ "candidates": [f" {lab[i]}" for i in range(len(v["choices"]))],
579
+ "subject": r.get("subject")}
580
+ out.append(rec)
581
+ rng.shuffle(out)
582
+ dump(a.out, out)
583
+ pos = collections.Counter(
584
+ (x["completion"] if isinstance(x["completion"], str) else x["completion"][0]["content"]).strip()
585
+ for x in out)
586
+ print(f"{len(rows)} questions -> {len(out)} training examples ({a.format}); "
587
+ f"not shuffled (positional options): {n_shuf_skip}")
588
+ print("answer label distribution:", dict(sorted(pos.items())))
589
+
590
+
591
+ def main():
592
+ # Windows consoles/redirects default to cp1252, which cannot encode Sinhala: force UTF-8
593
+ for stream in (sys.stdout, sys.stderr):
594
+ try:
595
+ stream.reconfigure(encoding="utf-8")
596
+ except Exception:
597
+ pass
598
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
599
+ sub = ap.add_subparsers(dest="cmd", required=True)
600
+ p = sub.add_parser("example"); p.add_argument("out"); p.set_defaults(f=cmd_example)
601
+ p = sub.add_parser("convert", help="PDF-extraction format -> MCQ schema")
602
+ p.add_argument("inp"); p.add_argument("out")
603
+ p.add_argument("--min_confidence", choices=["low", "medium", "high"], default="low")
604
+ p.add_argument("--skip_needs_review", action="store_true")
605
+ p.add_argument("--keep_shared_stem", action="store_true")
606
+ p.add_argument("--rejects", default="", help="save the rows dropped for Latin garbage to this file")
607
+ p.add_argument("--latin_allow", nargs="*", default=[], help="Latin tokens that are legitimate (e.g. CPU Excel RAM)")
608
+ p.add_argument("--keep_latin", action="store_true",
609
+ help="do not drop records with Latin tokens (use for science/maths/English papers)")
610
+ p.set_defaults(f=cmd_convert)
611
+ p = sub.add_parser("sample", help="print random records for hand-checking"); p.add_argument("inp")
612
+ p.add_argument("-n", type=int, default=20); p.add_argument("--seed", type=int, default=1)
613
+ p.add_argument("--out", default="", help="write to this file as UTF-8 (use this instead of '>' on Windows)")
614
+ p.set_defaults(f=cmd_sample)
615
+ p = sub.add_parser("validate"); p.add_argument("inp"); p.set_defaults(f=cmd_validate)
616
+ p = sub.add_parser("clean"); p.add_argument("inp"); p.add_argument("out")
617
+ p.add_argument("--rejects", default=""); p.add_argument("--keep_figures", action="store_true"); p.set_defaults(f=cmd_clean)
618
+ p = sub.add_parser("dedupe"); p.add_argument("inp"); p.add_argument("out")
619
+ p.add_argument("--blocklist", nargs="*", default=[], help="files of questions to remove overlaps with")
620
+ p.add_argument("--near_threshold", type=float, default=0.9)
621
+ p.add_argument("--block_threshold", type=float, default=0.5)
622
+ p.add_argument("--conflicts", default="conflicts.jsonl"); p.set_defaults(f=cmd_dedupe)
623
+ p = sub.add_parser("split"); p.add_argument("inp"); p.add_argument("--train", required=True)
624
+ p.add_argument("--val", required=True); p.add_argument("--val_frac", type=float, default=0.1)
625
+ p.add_argument("--group_key", default="metadata.source"); p.add_argument("--seed", type=int, default=3407)
626
+ p.set_defaults(f=cmd_split)
627
+ p = sub.add_parser("stats"); p.add_argument("inp"); p.set_defaults(f=cmd_stats)
628
+ p = sub.add_parser("build-sft"); p.add_argument("inp"); p.add_argument("out")
629
+ p.add_argument("--format", choices=["plain", "chat"], default="plain")
630
+ p.add_argument("--templates", nargs="*", choices=list(TEMPLATE_WEIGHTS), default=None)
631
+ p.add_argument("--shuffle_copies", type=int, default=1)
632
+ p.add_argument("--strip_zwj_prob", type=float, default=0.5)
633
+ p.add_argument("--seed", type=int, default=3407); p.set_defaults(f=cmd_build_sft)
634
+ a = ap.parse_args()
635
+ a.f(a)
636
+
637
+
638
+ if __name__ == "__main__":
639
+ main()
training/merge_adapter.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Merge a CPT LoRA adapter into its base model and save a plain 16-bit model folder.
4
+
5
+ Needed when the CPT run was uploaded as an ADAPTER (adapter_config.json + adapter_model.safetensors)
6
+ rather than as merged weights. After merging, the folder loads like any ordinary model, so
7
+ sft_gemma4_mcq.py can train on it with no changes:
8
+
9
+ python merge_adapter.py --adapter ovinduG/gemma4-e4b-sinhala-cpt-d3-c3ucsc \
10
+ --out /workspace/models/cpt_d3c3_merged
11
+ python sft_gemma4_mcq.py --train sft_train.jsonl --val sft_val.jsonl \
12
+ --model /workspace/models/cpt_d3c3_merged --out /workspace/outputs/mcq_sft_cpt_d3c3
13
+
14
+ Why merge instead of training the adapter directly: loading the adapter gives a PeftModel, and the
15
+ SFT script then adds a SECOND LoRA on top. Saving/merging that stack is error-prone, and the
16
+ parameter count for the 8B rule stops being obvious. Merging folds the CPT LoRA into the existing
17
+ weight matrices, so the result is still ~7.996B parameters.
18
+
19
+ Needs the GPU free (stop any training job first) and ~16 GB of free disk for the output.
20
+ """
21
+ import argparse
22
+ import json
23
+ import os
24
+ import struct
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
29
+
30
+ LIMIT = 8_000_000_000 # shared-task parameter cap
31
+
32
+
33
+ def adapter_info(src):
34
+ """Print what we are about to merge: base model and LoRA rank, read from adapter_config.json."""
35
+ cfg = None
36
+ p = Path(src) / "adapter_config.json"
37
+ if p.exists():
38
+ cfg = json.loads(p.read_text(encoding="utf-8"))
39
+ else:
40
+ try:
41
+ from huggingface_hub import hf_hub_download
42
+ cfg = json.loads(Path(hf_hub_download(src, "adapter_config.json")).read_text(encoding="utf-8"))
43
+ except Exception as e: # noqa: BLE001
44
+ print(f"[warn] could not read adapter_config.json ({type(e).__name__}: {e}); "
45
+ "if this is already a merged model you do not need this script")
46
+ return None
47
+ print(f"[adapter] base model : {cfg.get('base_model_name_or_path')}")
48
+ print(f"[adapter] LoRA r={cfg.get('r')} alpha={cfg.get('lora_alpha')} "
49
+ f"rslora={cfg.get('use_rslora')} targets={cfg.get('target_modules')}")
50
+ return cfg
51
+
52
+
53
+ def count_params(folder):
54
+ """Sum parameter counts from safetensors headers (no need to load the weights)."""
55
+ total = 0
56
+ for f in sorted(Path(folder).glob("*.safetensors")):
57
+ with open(f, "rb") as fh:
58
+ n = struct.unpack("<Q", fh.read(8))[0]
59
+ head = json.loads(fh.read(n))
60
+ for name, meta in head.items():
61
+ if name == "__metadata__":
62
+ continue
63
+ shape = meta.get("shape") or []
64
+ c = 1
65
+ for d in shape:
66
+ c *= d
67
+ total += c
68
+ return total
69
+
70
+
71
+ def main():
72
+ ap = argparse.ArgumentParser()
73
+ ap.add_argument("--adapter", required=True, help="HF repo id or local dir of the CPT LoRA adapter")
74
+ ap.add_argument("--out", required=True, help="local folder to write the merged 16-bit model to")
75
+ ap.add_argument("--max_seq_length", type=int, default=2048)
76
+ args = ap.parse_args()
77
+
78
+ if Path(args.out).exists() and list(Path(args.out).glob("*.safetensors")):
79
+ print(f"[skip] {args.out} already holds safetensors; delete it to rebuild")
80
+ n = count_params(args.out)
81
+ print(f"[check] parameters: {n:,} -> {'OK, under 8B' if n <= LIMIT else 'OVER THE 8B LIMIT'}")
82
+ return
83
+
84
+ adapter_info(args.adapter)
85
+
86
+ from unsloth import FastModel # imported late, as in the other scripts
87
+ print(f"[load] {args.adapter} (base weights download on first use, ~16 GB)")
88
+ model, tok = FastModel.from_pretrained(model_name=args.adapter, max_seq_length=args.max_seq_length,
89
+ dtype=None, load_in_4bit=False)
90
+ print(f"[merge] writing merged 16-bit model to {args.out}")
91
+ model.save_pretrained_merged(args.out, tok, save_method="merged_16bit")
92
+
93
+ files = sorted(p.name for p in Path(args.out).iterdir())
94
+ print(f"[done] {len(files)} files: {', '.join(files)}")
95
+ n = count_params(args.out)
96
+ print(f"[check] parameters: {n:,} -> {'OK, under 8B' if n <= LIMIT else 'OVER THE 8B LIMIT'}")
97
+ for need in ["config.json", "tokenizer.json", "tokenizer_config.json"]:
98
+ if need not in files:
99
+ print(f"[warn] {need} is missing from {args.out}; loading it may fail")
100
+ if not any(f.endswith(".safetensors") for f in files):
101
+ sys.exit("no safetensors were written: the merge failed")
102
+
103
+
104
+ if __name__ == "__main__":
105
+ main()
training/normalize_subjects.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Make the `subject` field consistent across the dataset.
4
+
5
+ Why it matters: build-sft puts the subject INTO the prompt ("විෂය: {subject}" / "Subject: {subject}"),
6
+ and the organisers' prompt always supplies the SINHALA subject name (metadata.subject_original, e.g.
7
+ "චිත්‍ර" for Arts). If some training rows carry an English or short name ("Dancing", "සිංහල") the model
8
+ sees a label at train time that never appears at test time, for exactly those subjects.
9
+
10
+ Three steps:
11
+
12
+ 1. see what the official test files actually use (the authoritative names):
13
+ python normalize_subjects.py --test_dir TEST/easy
14
+ 2. see what your dataset currently uses:
15
+ python normalize_subjects.py --inp data/all_converted.jsonl --list
16
+ 3. rewrite with the mapping below (edit MAPPING first if step 1 disagrees):
17
+ python normalize_subjects.py --inp data/all_converted.jsonl --out data/all_converted_norm.jsonl
18
+
19
+ Nothing else in the record is touched. Use --map_file to pass your own {"from": "to"} JSON instead.
20
+ """
21
+ import argparse
22
+ import collections
23
+ import glob
24
+ import json
25
+ import os
26
+ import sys
27
+
28
+ # English / short / variant name -> the SINHALA name the official test files use as
29
+ # metadata.subject_original. These were read off TEST/easy, not guessed.
30
+ MAPPING = {
31
+ # --- corrections to names already in the dataset -------------------------------------
32
+ "සිංහල භාෂාව හා සාහිත්‍යය": "සිංහල භාෂාව සහ සාහිත්‍යය", # හා -> සහ
33
+ "සෞඛ්‍ය හා ශාරීරික අධ්‍යාපනය": "සෞඛ්‍ය හා ශාරීරික විද්‍යාව", # අධ්‍යාපනය -> විද්‍යාව
34
+ "නැටුම්": "නර්තනය",
35
+ "සිංහල": "සිංහල භාෂාව සහ සාහිත්‍යය",
36
+ # --- English / variant labels from newer extractions ---------------------------------
37
+ "Arts": "චිත්‍ර",
38
+ "Art": "චිත්‍ර",
39
+ "art": "චිත්‍ර",
40
+ "Dancing": "නර්තනය",
41
+ "dancing": "නර්තනය",
42
+ "Buddhism": "බුද්ධ ධර්මය",
43
+ "Sinhala": "සිංහල භාෂාව සහ සාහිත්‍යය",
44
+ "Sinhala language and literature": "සිංහල භාෂාව සහ සාහිත්‍යය",
45
+ "Sinhala_language_and_literature": "සිංහල භාෂාව සහ සාහිත්‍යය",
46
+ "Eastern music": "පෙරදිග සංගීතය",
47
+ "Eastern_music": "පෙරදිග සංගීතය",
48
+ "drama and Theatre": "නාට්‍ය හා රංග කලාව",
49
+ "drama_and_theatre": "නාට්‍ය හා රංග කලාව",
50
+ "drama and theatre": "නාට්‍ය හා රංග කලාව",
51
+ "drama": "නාට්‍ය හා රංග කලාව",
52
+ "drama and theater": "නාට්‍ය හා රංග කලාව",
53
+ "theatre and drama": "නාට්‍ය හා රංග කලාව",
54
+ "eastern music": "පෙරදිග සංගීතය",
55
+ "Health and Physical Science": "සෞඛ්‍ය හා ශාරීරික විද්‍යාව",
56
+ "History": "ඉතිහාසය",
57
+ "Civics": "පුරවැසි අධ්‍යාපනය",
58
+ "Geography": "භූගෝල විද්‍යාව",
59
+ "Catholicism": "කතෝලික ධර්මය",
60
+ "Christianity": "ක්‍රිස්තියානි ධර්මය",
61
+ "Islam": "ඉස්ලාම් ධර්මය",
62
+ "science": "විද්‍යාව",
63
+ "Science": "විද්‍යාව",
64
+ }
65
+
66
+
67
+ def load_map(path):
68
+ """Read a {"from": "to"} JSON file, tolerating a UTF-8 BOM or UTF-16 (what Windows editors write)."""
69
+ raw = open(path, "rb").read()
70
+ if not raw.strip():
71
+ sys.exit(f"{path} is empty")
72
+ for enc in ("utf-8-sig", "utf-16", "utf-8"):
73
+ try:
74
+ txt = raw.decode(enc)
75
+ except (UnicodeDecodeError, UnicodeError):
76
+ continue
77
+ try:
78
+ m = json.loads(txt.lstrip("\ufeff"))
79
+ except json.JSONDecodeError:
80
+ continue
81
+ if not isinstance(m, dict):
82
+ sys.exit(f"{path} must contain a JSON object of {{\"from\": \"to\"}} pairs")
83
+ print(f"[map] {len(m)} override(s) read from {path} ({enc})")
84
+ return {str(k): str(v) for k, v in m.items()}
85
+ sys.exit(f"could not parse {path} as JSON in utf-8/utf-8-sig/utf-16. First bytes: {raw[:40]!r}")
86
+
87
+
88
+ def read_jsonl(path):
89
+ rows = []
90
+ with open(path, encoding="utf-8-sig") as f:
91
+ for line in f:
92
+ line = line.strip()
93
+ if line:
94
+ rows.append(json.loads(line))
95
+ return rows
96
+
97
+
98
+ def show_test_names(test_dir):
99
+ """Print the subject / subject_original pairs the official test files use."""
100
+ files = sorted(glob.glob(os.path.join(test_dir, "**", "*.json"), recursive=True))
101
+ if not files:
102
+ sys.exit(f"no .json files under {test_dir}")
103
+ c = collections.Counter()
104
+ for p in files:
105
+ try:
106
+ data = json.load(open(p, encoding="utf-8"))
107
+ except Exception as e: # noqa: BLE001
108
+ print(f"[warn] {os.path.basename(p)}: {type(e).__name__}")
109
+ continue
110
+ if isinstance(data, list):
111
+ for it in data:
112
+ md = it.get("metadata") or {}
113
+ c[(it.get("subject"), md.get("subject_original"))] += 1
114
+ print(f"official test subjects in {test_dir} "
115
+ f"(subject_original is what goes into THEIR prompt):")
116
+ print(f"{'n':>6} {'subject':<34}{'subject_original'}")
117
+ for (s, so), n in sorted(c.items(), key=lambda x: -x[1]):
118
+ print(f"{n:>6} {str(s):<34}{so}")
119
+
120
+
121
+ def main():
122
+ ap = argparse.ArgumentParser()
123
+ ap.add_argument("--inp", default="")
124
+ ap.add_argument("--out", default="")
125
+ ap.add_argument("--list", action="store_true", help="just print the current subject counts")
126
+ ap.add_argument("--test_dir", default="", help="print the official test files' subject names")
127
+ ap.add_argument("--map_file", default="", help='JSON file of {"from": "to"} overrides')
128
+ args = ap.parse_args()
129
+ for stream in (sys.stdout, sys.stderr):
130
+ try:
131
+ stream.reconfigure(encoding="utf-8")
132
+ except Exception: # noqa: BLE001
133
+ pass
134
+
135
+ if args.test_dir:
136
+ show_test_names(args.test_dir)
137
+ if not args.inp:
138
+ return
139
+ print()
140
+
141
+ if not args.inp:
142
+ sys.exit("give --inp (and --out, unless --list)")
143
+ rows = read_jsonl(args.inp)
144
+ before = collections.Counter(r.get("subject") or "?" for r in rows)
145
+
146
+ if args.list or not args.out:
147
+ print(f"{len(rows)} rows in {args.inp}; subjects:")
148
+ for s, n in before.most_common():
149
+ flag = " <-- would be renamed" if s in MAPPING else ""
150
+ print(f"{n:>6} {s}{flag}")
151
+ if not args.out:
152
+ return
153
+
154
+ mapping = dict(MAPPING)
155
+ if args.map_file:
156
+ mapping.update(load_map(args.map_file))
157
+
158
+ ci = {k.lower(): v for k, v in mapping.items()}
159
+ changed = collections.Counter()
160
+ for r in rows:
161
+ s = r.get("subject")
162
+ # normalise stray whitespace too: the official files contain "drama and Theatre " with a
163
+ # trailing space, which would otherwise be a separate bucket
164
+ if isinstance(s, str):
165
+ s2 = " ".join(s.split())
166
+ if s2 != s:
167
+ r["subject"] = s = s2
168
+ tgt = mapping.get(s)
169
+ if tgt is None and isinstance(s, str):
170
+ tgt = ci.get(s.lower()) # Sinhala has no case, so lowering is safe
171
+ if tgt and tgt != s:
172
+ changed[(s, tgt)] += 1
173
+ r["subject"] = tgt
174
+
175
+ with open(args.out, "w", encoding="utf-8") as f:
176
+ for r in rows:
177
+ f.write(json.dumps(r, ensure_ascii=False) + "\n")
178
+
179
+ if changed:
180
+ print("renamed:")
181
+ for (a, b), n in changed.most_common():
182
+ print(f"{n:>6} {a!r} -> {b!r}")
183
+ else:
184
+ print("nothing to rename (subjects already consistent)")
185
+ after = collections.Counter(r.get("subject") or "?" for r in rows)
186
+ print(f"\n[out] {len(rows)} rows -> {args.out}")
187
+ print(f"distinct subjects: {len(before)} -> {len(after)}")
188
+
189
+
190
+ if __name__ == "__main__":
191
+ main()
training/push_merged_to_hf.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Merge a LoRA adapter into its base model and push the merged 16-bit weights STRAIGHT TO THE HUB,
4
+ without ever writing a full ~16 GB copy to local disk.
5
+
6
+ Use this when `upload_to_hf.py --make_merged` fails with:
7
+ RuntimeError: Unsloth: Failed saving locally - no disk space left. Uploading can work luckily!
8
+
9
+ export HF_TOKEN=hf_...
10
+ python push_merged_to_hf.py \
11
+ --adapter /workspace/outputs/mcq_sft_cpt_d3c3/best_adapter \
12
+ --repo ovinduG/gemma4-e4b-sinhala-mcq
13
+
14
+ Add --public to make the repo public (default: private).
15
+ Afterwards it reads the parameter count back FROM THE HUB (no download) to check the 8B rule.
16
+
17
+ The adapter's base model must still be resolvable: either the local folder recorded in
18
+ adapter_config.json ("base_model_name_or_path") or a Hub repo id.
19
+ """
20
+ import argparse
21
+ import inspect
22
+ import json
23
+ import os
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
28
+
29
+ LIMIT = 8_000_000_000
30
+
31
+
32
+ def show_base(adapter):
33
+ """`adapter` may be a local folder OR a Hub repo id."""
34
+ p = Path(adapter) / "adapter_config.json"
35
+ if p.exists():
36
+ cfg = json.loads(p.read_text(encoding="utf-8"))
37
+ else:
38
+ try:
39
+ from huggingface_hub import hf_hub_download
40
+ cfg = json.loads(Path(hf_hub_download(adapter, "adapter_config.json"))
41
+ .read_text(encoding="utf-8"))
42
+ print(f"[adapter] read adapter_config.json from the Hub repo {adapter}")
43
+ except Exception as e:
44
+ sys.exit(f"could not read adapter_config.json from {adapter} "
45
+ f"({type(e).__name__}: {e}). Give a local adapter folder or a Hub repo id "
46
+ "that contains adapter_config.json + adapter_model.safetensors.")
47
+ base = cfg.get("base_model_name_or_path")
48
+ print(f"[adapter] LoRA r={cfg.get('r')} alpha={cfg.get('lora_alpha')}")
49
+ print(f"[adapter] base model: {base}")
50
+ if base and ("/" in str(base)) and Path(base).is_absolute():
51
+ if not Path(base).exists():
52
+ sys.exit(f"base model folder {base} is missing: re-create it (merge_adapter.py) or edit "
53
+ "base_model_name_or_path in adapter_config.json to a Hub repo id")
54
+ print("[adapter] base folder found locally")
55
+ return base
56
+
57
+
58
+ def verify_on_hub(repo, token):
59
+ """Read parameter counts from the uploaded safetensors headers without downloading the weights."""
60
+ try:
61
+ from huggingface_hub import get_safetensors_metadata
62
+ md = get_safetensors_metadata(repo, token=token)
63
+ counts = getattr(md, "parameter_count", None) or {}
64
+ total = sum(counts.values())
65
+ print(f"[verify] parameter count on the Hub: {total:,} ({dict(counts)})")
66
+ print(f"[verify] 8B rule: {'OK, under 8,000,000,000' if total <= LIMIT else 'OVER THE LIMIT'}")
67
+ return total
68
+ except Exception as e: # noqa: BLE001
69
+ print(f"[verify] could not read parameter count ({type(e).__name__}: {e}); "
70
+ "check the repo's Files tab by hand")
71
+ return None
72
+
73
+
74
+ def main():
75
+ ap = argparse.ArgumentParser()
76
+ ap.add_argument("--adapter", required=True, help="LoRA adapter folder (e.g. .../best_adapter)")
77
+ ap.add_argument("--repo", required=True, help="target repo, e.g. ovinduG/gemma4-e4b-sinhala-mcq")
78
+ ap.add_argument("--max_seq_length", type=int, default=2048)
79
+ ap.add_argument("--public", action="store_true", help="make the repo public (default: private)")
80
+ ap.add_argument("--token", default="")
81
+ args = ap.parse_args()
82
+
83
+ token = args.token or os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
84
+ if not token:
85
+ sys.exit("No token. Run: export HF_TOKEN=hf_... (or pass --token)")
86
+
87
+ show_base(args.adapter)
88
+
89
+ from unsloth import FastModel
90
+ print(f"[load] {args.adapter}")
91
+ model, tok = FastModel.from_pretrained(model_name=args.adapter, max_seq_length=args.max_seq_length,
92
+ dtype=None, load_in_4bit=False)
93
+
94
+ fn = getattr(model, "push_to_hub_merged", None)
95
+ if fn is None:
96
+ sys.exit("this Unsloth build has no push_to_hub_merged; upgrade unsloth, or free ~16 GB and "
97
+ "use upload_to_hf.py --make_merged")
98
+
99
+ # build kwargs from whatever this Unsloth version actually accepts
100
+ params = set(inspect.signature(fn).parameters)
101
+ kw = {}
102
+ for name, val in [("save_method", "merged_16bit"), ("token", token),
103
+ ("private", not args.public), ("tokenizer", tok)]:
104
+ if name in params:
105
+ kw[name] = val
106
+ print(f"[push] -> {args.repo} ({'public' if args.public else 'private'}); "
107
+ f"streaming shards, nothing large written locally")
108
+ print(f"[push] extra args accepted by this build: {sorted(kw)}")
109
+ try:
110
+ if "tokenizer" in kw:
111
+ fn(args.repo, **kw)
112
+ else: # older signature takes the tokenizer positionally
113
+ kw.pop("tokenizer", None)
114
+ fn(args.repo, tok, **kw)
115
+ except TypeError as e:
116
+ print(f"[push] signature mismatch ({e}); retrying with the minimal call")
117
+ fn(args.repo, tok, save_method="merged_16bit", token=token)
118
+
119
+ print("[push] done")
120
+ verify_on_hub(args.repo, token)
121
+ print("\nSubmit the MERGED repo (not the adapter): merging adds no parameters, so it stays "
122
+ "under 8B, whereas base + a 73.4M adapter does not.")
123
+
124
+
125
+ if __name__ == "__main__":
126
+ main()
training/run_data_pipeline.sh ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Rebuilds the SFT dataset from the raw PDF extractions. CPU only, a few minutes.
3
+ #
4
+ # bash run_data_pipeline.sh
5
+ #
6
+ # Inputs expected in ./data :
7
+ # candidates.jsonl bulk extraction (9,409 rows, O/L + Grade 10-11)
8
+ # art.jsonl buddhim.jsonl dancing.jsonl sinhala.jsonl Eastern_music.jsonl drama.jsonl
9
+ # later per-subject extractions
10
+ # Plus the public SinhalaMMLU TEST/easy split, used ONLY as a decontamination blocklist.
11
+ set -euo pipefail
12
+
13
+ # 0. public test split -> decontamination blocklist (never used for training or tuning)
14
+ hf download naist-nlp/SinhalaMMLU --repo-type dataset --include "TEST/easy/*" --local-dir .
15
+ python eval_sinhalammlu.py blocklist --data TEST/easy --out test_easy_block.jsonl
16
+
17
+ # 1. bulk extraction -> MCQ schema
18
+ python mcq_dataset.py convert data/candidates.jsonl data/converted.jsonl \
19
+ --rejects data/latin_rejects.jsonl
20
+
21
+ # 2. merge the per-subject extractions
22
+ python add_new_data.py --base data/converted.jsonl \
23
+ --new data/art.jsonl data/buddhim.jsonl data/dancing.jsonl data/sinhala.jsonl \
24
+ data/Eastern_music.jsonl data/drama.jsonl \
25
+ --out data/all_converted_v3.jsonl
26
+
27
+ # 3. align subject names with the official metadata.subject_original values
28
+ python normalize_subjects.py --inp data/all_converted_v3.jsonl --out data/all_converted_norm_v3.jsonl
29
+
30
+ # 4. clean, decontaminate, split
31
+ python mcq_dataset.py validate data/all_converted_norm_v3.jsonl
32
+ python mcq_dataset.py clean data/all_converted_norm_v3.jsonl data/all_cleaned_v3.jsonl \
33
+ --rejects data/all_clean_rejects_v3.jsonl
34
+ python mcq_dataset.py dedupe data/all_cleaned_v3.jsonl data/all_deduped_v3.jsonl \
35
+ --blocklist test_easy_block.jsonl --conflicts data/all_conflicts_v3.jsonl
36
+ python mcq_dataset.py stats data/all_deduped_v3.jsonl
37
+ python mcq_dataset.py split data/all_deduped_v3.jsonl \
38
+ --train data/train3.jsonl --val data/val3.jsonl --val_frac 0.1 --seed 3407
39
+
40
+ # 5. build the prompt/completion files
41
+ python mcq_dataset.py build-sft data/train3.jsonl data/sft_train3.jsonl \
42
+ --shuffle_copies 2 --templates si_instruction bare en_instruction --seed 3407
43
+ python mcq_dataset.py build-sft data/val3.jsonl data/sft_val3.jsonl \
44
+ --shuffle_copies 0 --strip_zwj_prob 0 --templates si_instruction --seed 3407
45
+
46
+ echo
47
+ echo "== expected: 7,872 questions after dedupe (26 removed as test overlaps),"
48
+ echo "== 7,151 train -> 21,235 examples, 721 validation."
training/run_inference.sh ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Reproduces the submitted predictions end-to-end. No human in the loop.
3
+ #
4
+ # bash run_inference.sh /path/to/test_root [output_dir]
5
+ #
6
+ # test_root is the directory holding easy/ medium/ hard/ (or any folder of the test .json files).
7
+ # Writes predictions.jsonl and predictions.zip into output_dir (default: ./out).
8
+ #
9
+ # Deterministic: no sampling, no generation, fixed seed, argmax over summed log-softmax scores.
10
+ # Total inference pipeline = one 7,996,156,448-parameter model (<= 8B).
11
+ set -euo pipefail
12
+
13
+ TEST_ROOT="${1:?usage: bash run_inference.sh <test_root> [out_dir]}"
14
+ OUT_DIR="${2:-./out}"
15
+ MODEL="QBitsLK/gemma4-e4b-sinhala-mcq-cpt-v3"
16
+ VARIANTS="si_intro si_nointro en_intro si_rot1 si_rot2"
17
+
18
+ mkdir -p "$OUT_DIR"
19
+ echo "== model : $MODEL"
20
+ echo "== variants : $VARIANTS"
21
+ echo "== test root: $TEST_ROOT"
22
+
23
+ python make_predictions.py \
24
+ --data "$TEST_ROOT" \
25
+ --model "$MODEL" \
26
+ --ensemble $VARIANTS \
27
+ --seed 3407 \
28
+ --out "$OUT_DIR/predictions.jsonl" \
29
+ --zip "$OUT_DIR/predictions.zip"
30
+
31
+ echo
32
+ echo "== wrote $OUT_DIR/predictions.zip (contains predictions.jsonl at the archive root)"
training/run_training.sh ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Reproduces the fine-tuning run behind the submitted model.
3
+ #
4
+ # bash run_training.sh [out_dir]
5
+ #
6
+ # Needs: one 48 GB GPU (reference: NVIDIA A40), ~17 GB disk for the base weights.
7
+ # Runtime on the reference machine: 88 minutes, peak VRAM 28.0 GB.
8
+ #
9
+ # The SFT files are published at:
10
+ # https://huggingface.co/datasets/QBitsLK/sinhala-mcq-sft-v3
11
+ # and are downloaded here. run_data_pipeline.sh rebuilds them from the raw extractions.
12
+ set -euo pipefail
13
+
14
+ OUT_DIR="${1:-/workspace/outputs/mcq_sft_cpt_v3}"
15
+ BASE_MODEL="QBitsLK/gemma4-e4b-sinhala-cpt"
16
+ DATA_REPO="QBitsLK/sinhala-mcq-sft-v3"
17
+
18
+ hf download "$DATA_REPO" --repo-type dataset --local-dir ./sft_data
19
+ TRAIN=./sft_data/sft_train3.jsonl
20
+ VAL=./sft_data/sft_val3.jsonl
21
+ wc -l "$TRAIN" "$VAL" # expect 21235 and 721
22
+
23
+ python sft_gemma4_mcq.py \
24
+ --train "$TRAIN" --val "$VAL" \
25
+ --model "$BASE_MODEL" \
26
+ --out "$OUT_DIR" \
27
+ --lora_r 32 --lora_alpha 32 --lr 1e-4 \
28
+ --epochs 2 --batch_size 8 --grad_accum 4 \
29
+ --max_seq_length 1024 --warmup_steps 10 \
30
+ --eval_steps 150 --seed 3407
31
+
32
+ echo
33
+ echo "== the submitted checkpoint is $OUT_DIR/final_adapter (step 1328), NOT best_adapter."
34
+ echo "== see SYSTEM_DESCRIPTION.md section 5.2 for why."
training/sft_gemma4_mcq.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Supervised fine-tuning of Gemma 4 E4B for Sinhala multiple-choice QA (SinhalaMMLU-style), Unsloth + LoRA.
4
+
5
+ Input: the prompt/completion files made by `mcq_dataset.py build-sft` (plain format, with a `candidates` field).
6
+ The loss is computed ONLY on the answer tokens. Accuracy is measured separately by scoring every answer
7
+ option on the held-out validation file (before training = zero-shot baseline, then after every epoch).
8
+
9
+ # base model
10
+ python sft_gemma4_mcq.py --train sft_train.jsonl --val sft_val.jsonl \
11
+ --model unsloth/gemma-4-E4B --out /workspace/outputs/mcq_sft_base
12
+ # CPT model (local merged folder from the earlier run)
13
+ python sft_gemma4_mcq.py --train sft_train.jsonl --val sft_val.jsonl \
14
+ --model /workspace/outputs/gemma4_e4b_sinhala_cpt/merged_16bit --out /workspace/outputs/mcq_sft_cpt
15
+ # zero-shot baseline only (no training)
16
+ python sft_gemma4_mcq.py --val sft_val.jsonl --model unsloth/gemma-4-E4B --eval_only
17
+
18
+ Outputs: <out>/best_adapter (best validation accuracy), <out>/final_adapter, <out>/history.json
19
+ To merge + upload the best adapter: python upload_to_hf.py --adapter_dir <out>/best_adapter --make_merged ...
20
+ """
21
+ import argparse
22
+ import inspect
23
+ import json
24
+ import os
25
+ import random
26
+ import sys
27
+ import time
28
+
29
+ os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
30
+
31
+
32
+ # ------------------------------------------------------------------ helpers (no GPU needed)
33
+ def load_rows(path):
34
+ rows = [json.loads(l) for l in open(path, encoding="utf-8") if l.strip()]
35
+ for r in rows[:3]:
36
+ if not isinstance(r.get("prompt"), str) or not isinstance(r.get("candidates"), list):
37
+ sys.exit(f"{path}: need plain prompt/completion rows with a 'candidates' list "
38
+ "(re-run mcq_dataset.py build-sft without --format chat)")
39
+ return rows
40
+
41
+
42
+ def common_prefix_len(a, b):
43
+ n = 0
44
+ for x, y in zip(a, b):
45
+ if x != y:
46
+ break
47
+ n += 1
48
+ return n
49
+
50
+
51
+ def encode_example(tok, prompt, completion, add_eos=True):
52
+ """Tokenise prompt+completion jointly (as a test harness would); mask everything before the first
53
+ token that differs from the prompt-only tokenisation. Returns input_ids, labels."""
54
+ p_ids = tok(prompt)["input_ids"]
55
+ f_ids = tok(prompt + completion)["input_ids"]
56
+ k = max(1, common_prefix_len(p_ids, f_ids))
57
+ ids = list(f_ids) + ([tok.eos_token_id] if add_eos and tok.eos_token_id is not None else [])
58
+ labels = [-100] * k + ids[k:]
59
+ return ids, labels
60
+
61
+
62
+ def evaluate(model, tok, rows, max_len=1024, limit=0, batch_seqs=16, verbose=True):
63
+ """Accuracy of picking the option whose answer tokens get the highest total log-probability.
64
+ Every (question, candidate) pair is scored; pairs are batched together for speed."""
65
+ import torch
66
+ was_training = model.training
67
+ model.eval()
68
+ dev = next(model.parameters()).device
69
+ pad = tok.pad_token_id if tok.pad_token_id is not None else 0
70
+ use = rows[:limit] if limit else rows
71
+ t0 = time.time()
72
+
73
+ items, skipped = [], 0 # (row_idx, cand_idx, token_ids, answer_start)
74
+ for ri, r in enumerate(use):
75
+ p_ids = tok(r["prompt"])["input_ids"]
76
+ seqs, starts = [], []
77
+ for c in r["candidates"]:
78
+ f = tok(r["prompt"] + c)["input_ids"]
79
+ seqs.append(f)
80
+ starts.append(max(1, common_prefix_len(p_ids, f)))
81
+ if max(len(x) for x in seqs) > max_len:
82
+ skipped += 1
83
+ continue
84
+ for ci, (sq, k) in enumerate(zip(seqs, starts)):
85
+ items.append((ri, ci, sq, k))
86
+
87
+ scores = {}
88
+ with torch.no_grad():
89
+ for i in range(0, len(items), batch_seqs):
90
+ chunk = items[i:i + batch_seqs]
91
+ L = max(len(x[2]) for x in chunk)
92
+ ids = torch.full((len(chunk), L), pad, dtype=torch.long)
93
+ mask = torch.zeros((len(chunk), L), dtype=torch.long)
94
+ for b, (_, _, sq, _) in enumerate(chunk):
95
+ ids[b, :len(sq)] = torch.tensor(sq)
96
+ mask[b, :len(sq)] = 1
97
+ logits = model(input_ids=ids.to(dev), attention_mask=mask.to(dev)).logits
98
+ for b, (ri, ci, sq, k) in enumerate(chunk):
99
+ lp = logits[b, k - 1:len(sq) - 1].float().log_softmax(-1)
100
+ tgt = ids[b, k:len(sq)].to(dev)
101
+ scores[(ri, ci)] = lp.gather(-1, tgt[:, None]).sum().item()
102
+ del logits
103
+
104
+ n = correct = 0
105
+ by_subj, gold_pos = {}, {}
106
+ for ri, r in enumerate(use):
107
+ cs = [scores.get((ri, ci)) for ci in range(len(r["candidates"]))]
108
+ if any(c is None for c in cs):
109
+ continue
110
+ pred = max(range(len(cs)), key=lambda i: cs[i])
111
+ gold = r["candidates"].index(r["completion"])
112
+ n += 1
113
+ correct += int(pred == gold)
114
+ gold_pos[gold] = gold_pos.get(gold, 0) + 1
115
+ sj = r.get("subject") or "?"
116
+ c_, t_ = by_subj.get(sj, (0, 0))
117
+ by_subj[sj] = (c_ + int(pred == gold), t_ + 1)
118
+
119
+ if was_training:
120
+ model.train()
121
+ acc = correct / max(1, n)
122
+ majority = max(gold_pos.values()) / max(1, n) if gold_pos else 0.0
123
+ if verbose:
124
+ print(f"[eval] accuracy {acc:.4f} ({correct}/{n}); always-guess-most-common-position {majority:.4f}; "
125
+ f"skipped(too long) {skipped}; {time.time() - t0:.0f}s", flush=True)
126
+ return {"acc": acc, "n": n, "majority_baseline": majority,
127
+ "by_subject": {k: round(v[0] / v[1], 4) for k, v in sorted(by_subj.items()) if v[1] >= 20}}
128
+
129
+
130
+ # ------------------------------------------------------------------ main
131
+ def main():
132
+ ap = argparse.ArgumentParser()
133
+ ap.add_argument("--train", default="")
134
+ ap.add_argument("--val", required=True)
135
+ ap.add_argument("--model", default="unsloth/gemma-4-E4B")
136
+ ap.add_argument("--out", default="/workspace/outputs/mcq_sft")
137
+ ap.add_argument("--max_seq_length", type=int, default=1024)
138
+ ap.add_argument("--lora_r", type=int, default=32)
139
+ ap.add_argument("--lora_alpha", type=int, default=32)
140
+ ap.add_argument("--lr", type=float, default=1e-4)
141
+ ap.add_argument("--epochs", type=float, default=3.0)
142
+ ap.add_argument("--max_steps", type=int, default=-1, help=">0 overrides epochs (smoke test)")
143
+ ap.add_argument("--batch_size", type=int, default=8)
144
+ ap.add_argument("--grad_accum", type=int, default=4)
145
+ ap.add_argument("--warmup_steps", type=int, default=10)
146
+ ap.add_argument("--eval_limit", type=int, default=0, help="evaluate on the first N val rows only (0 = all)")
147
+ ap.add_argument("--eval_batch", type=int, default=16, help="candidate sequences scored per forward pass")
148
+ ap.add_argument("--eval_steps", type=int, default=0,
149
+ help="also evaluate every N optimizer steps (0 = only at epoch ends). "
150
+ "Use it when the model converges early: the best checkpoint may be mid-epoch.")
151
+ ap.add_argument("--skip_baseline", action="store_true")
152
+ ap.add_argument("--eval_only", action="store_true", help="just score the model on --val (zero-shot baseline)")
153
+ ap.add_argument("--seed", type=int, default=3407)
154
+ args = ap.parse_args()
155
+ if not args.eval_only and not args.train:
156
+ sys.exit("--train is required unless --eval_only")
157
+
158
+ from unsloth import FastModel # must come before transformers / trl / peft
159
+ import torch
160
+ from datasets import Dataset
161
+ from transformers import DataCollatorForSeq2Seq, TrainerCallback
162
+ from trl import SFTConfig, SFTTrainer
163
+
164
+ random.seed(args.seed)
165
+ os.makedirs(args.out, exist_ok=True)
166
+ json.dump(vars(args), open(os.path.join(args.out, "run_config.json"), "w"), indent=1)
167
+ print(f"[model] loading {args.model}")
168
+ model, processor = FastModel.from_pretrained(model_name=args.model, dtype=None,
169
+ max_seq_length=args.max_seq_length,
170
+ load_in_4bit=False, full_finetuning=False)
171
+ tok = getattr(processor, "tokenizer", processor)
172
+ if tok.pad_token is None:
173
+ tok.pad_token = tok.eos_token
174
+
175
+ val_rows = load_rows(args.val)
176
+ print(f"[data] val rows: {len(val_rows)}")
177
+
178
+ if args.eval_only:
179
+ res = evaluate(model, tok, val_rows, args.max_seq_length, args.eval_limit, args.eval_batch)
180
+ json.dump(res, open(os.path.join(args.out, "eval_only.json"), "w"), ensure_ascii=False, indent=1)
181
+ return
182
+
183
+ model = FastModel.get_peft_model(
184
+ model, finetune_vision_layers=False, finetune_language_layers=True,
185
+ finetune_attention_modules=True, finetune_mlp_modules=True,
186
+ r=args.lora_r, lora_alpha=args.lora_alpha, lora_dropout=0, bias="none", random_state=args.seed,
187
+ use_gradient_checkpointing="unsloth")
188
+ try:
189
+ model.print_trainable_parameters()
190
+ except Exception:
191
+ pass
192
+
193
+ train_rows = load_rows(args.train)
194
+ enc, dropped = [], 0
195
+ for r in train_rows:
196
+ ids, labels = encode_example(tok, r["prompt"], r["completion"])
197
+ if len(ids) > args.max_seq_length:
198
+ dropped += 1
199
+ continue
200
+ enc.append({"input_ids": ids, "attention_mask": [1] * len(ids), "labels": labels})
201
+ print(f"[data] train examples: {len(enc)} (dropped {dropped} longer than {args.max_seq_length} tokens)")
202
+ train_ds = Dataset.from_list(enc)
203
+
204
+ coll_kw = dict(padding=True, label_pad_token_id=-100, pad_to_multiple_of=8)
205
+ coll_params = inspect.signature(DataCollatorForSeq2Seq.__init__).parameters
206
+ if "tokenizer" in coll_params:
207
+ collator = DataCollatorForSeq2Seq(tokenizer=tok, **coll_kw)
208
+ else: # transformers >=5 renamed it
209
+ collator = DataCollatorForSeq2Seq(processing_class=tok, **coll_kw)
210
+ # --- masking sanity check: only the answer tokens may carry a label
211
+ b = collator([enc[0], enc[min(1, len(enc) - 1)]])
212
+ frac = float((b["labels"] != -100).float().mean())
213
+ shown = tok.decode([t for t in b["labels"][0].tolist() if t != -100])
214
+ print(f"[mask] labelled-token fraction {frac:.3f}; example 0 trains on: {shown!r}")
215
+ if frac > 0.3:
216
+ sys.exit("Masking looks wrong (too many labelled tokens). Stop and check the data format.")
217
+
218
+ history = []
219
+ best = {"acc": -1.0}
220
+
221
+ def record(tag, res, step):
222
+ history.append({"tag": tag, "step": step, **res})
223
+ json.dump(history, open(os.path.join(args.out, "history.json"), "w"), ensure_ascii=False, indent=1)
224
+
225
+ if not args.skip_baseline:
226
+ print("[baseline] zero-shot accuracy before training")
227
+ record("baseline", evaluate(model, tok, val_rows, args.max_seq_length, args.eval_limit, args.eval_batch), 0)
228
+
229
+ class EpochEval(TrainerCallback):
230
+ last = -1
231
+
232
+ def _run(self, tag, state, model):
233
+ if state.global_step == self.last:
234
+ return
235
+ self.last = state.global_step
236
+ res = evaluate(model, tok, val_rows, args.max_seq_length, args.eval_limit, args.eval_batch)
237
+ record(tag, res, state.global_step)
238
+ if res["acc"] > best["acc"]:
239
+ best.update(res, step=state.global_step)
240
+ d = os.path.join(args.out, "best_adapter")
241
+ model.save_pretrained(d)
242
+ processor.save_pretrained(d)
243
+ print(f"[save] new best accuracy {res['acc']:.4f} -> {d}")
244
+
245
+ def on_step_end(self, a, state, control, model=None, **kw):
246
+ if args.eval_steps > 0 and state.global_step > 0 and state.global_step % args.eval_steps == 0:
247
+ self._run(f"step {state.global_step}", state, model)
248
+
249
+ def on_epoch_end(self, a, state, control, model=None, **kw):
250
+ self._run(f"epoch {state.epoch:.2f}", state, model)
251
+
252
+ def on_train_end(self, a, state, control, model=None, **kw):
253
+ self._run("end", state, model)
254
+
255
+ cfg_kw = dict(
256
+ output_dir=os.path.join(args.out, "trainer"), per_device_train_batch_size=args.batch_size,
257
+ gradient_accumulation_steps=args.grad_accum, learning_rate=args.lr, lr_scheduler_type="cosine",
258
+ warmup_steps=args.warmup_steps, weight_decay=0.01, max_grad_norm=1.0,
259
+ num_train_epochs=args.epochs, max_steps=args.max_steps, bf16=True, optim="adamw_8bit",
260
+ logging_steps=10, save_strategy="no", seed=args.seed, report_to="none",
261
+ remove_unused_columns=False, dataset_text_field="", max_length=args.max_seq_length)
262
+ if "dataset_kwargs" in inspect.signature(SFTConfig.__init__).parameters:
263
+ cfg_kw["dataset_kwargs"] = {"skip_prepare_dataset": True}
264
+ trainer = SFTTrainer(model=model, processing_class=tok, train_dataset=train_ds,
265
+ data_collator=collator, args=SFTConfig(**cfg_kw), callbacks=[EpochEval()])
266
+ stats = trainer.train()
267
+ print(f"[done] {stats.metrics.get('train_runtime', 0) / 60:.1f} min; "
268
+ f"peak VRAM {torch.cuda.max_memory_reserved() / 1024 ** 3:.1f} GB")
269
+
270
+ final = os.path.join(args.out, "final_adapter")
271
+ model.save_pretrained(final)
272
+ processor.save_pretrained(final)
273
+ print(f"[save] final adapter -> {final}")
274
+ print("\nValidation accuracy history:")
275
+ for h in history:
276
+ print(f" {h['tag']:>12s} step {h['step']:5d} acc {h['acc']:.4f} (n={h['n']}, "
277
+ f"majority-position baseline {h['majority_baseline']:.3f})")
278
+ print(f"best: {best['acc']:.4f} at step {best.get('step')} -> {os.path.join(args.out, 'best_adapter')}")
279
+
280
+
281
+ if __name__ == "__main__":
282
+ main()
training/unsloth_compiled_cache/.locks/.lock.AqlmLoraLinear_peft_forward.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.AwqLoraLinear_peft_forward.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.BatchNorm1d.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.BatchNorm2d.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.BatchNorm3d.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.BlockDiagonalLinear_peft_forward.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.Conv1d.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.Conv2d.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.Conv3d.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.ConvTranspose1d.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.ConvTranspose2d.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.ConvTranspose3d.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.GPTQLoraLinear_peft_forward.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.GroupNorm.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.LayerNorm.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.Linear4bit_peft_forward.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.Linear8bitLt_peft_forward.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.Linear_peft_forward.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.LoraParallelLinear_peft_forward.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.RMSNorm.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.TeLinear_peft_forward.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.UnslothCPOTrainer.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.UnslothDPOTrainer.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.UnslothDistillationTrainer.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.UnslothGKDTrainer.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.UnslothGOLDTrainer.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.UnslothGRPOTrainer.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.UnslothKTOTrainer.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.UnslothORPOTrainer.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.UnslothOnlineDPOTrainer.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.UnslothRLOOTrainer.py ADDED
File without changes
training/unsloth_compiled_cache/.locks/.lock.UnslothRewardTrainer.py ADDED
File without changes