Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .ipynb_checkpoints/README-checkpoint.md +86 -0
- README.md +86 -0
- inference.py +277 -0
- predictions.jsonl +0 -0
- requirements.txt +15 -0
- training/REPRODUCE.md +172 -0
- training/add_new_data.py +174 -0
- training/check_env.py +162 -0
- training/eval_sinhalammlu.py +396 -0
- training/make_predictions.py +487 -0
- training/mcq_dataset.py +639 -0
- training/merge_adapter.py +105 -0
- training/normalize_subjects.py +191 -0
- training/push_merged_to_hf.py +126 -0
- training/run_data_pipeline.sh +48 -0
- training/run_inference.sh +32 -0
- training/run_training.sh +34 -0
- training/sft_gemma4_mcq.py +282 -0
- training/unsloth_compiled_cache/.locks/.lock.AqlmLoraLinear_peft_forward.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.AwqLoraLinear_peft_forward.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.BatchNorm1d.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.BatchNorm2d.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.BatchNorm3d.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.BlockDiagonalLinear_peft_forward.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.Conv1d.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.Conv2d.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.Conv3d.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.ConvTranspose1d.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.ConvTranspose2d.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.ConvTranspose3d.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.GPTQLoraLinear_peft_forward.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.GroupNorm.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.LayerNorm.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.Linear4bit_peft_forward.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.Linear8bitLt_peft_forward.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.Linear_peft_forward.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.LoraParallelLinear_peft_forward.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.RMSNorm.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.TeLinear_peft_forward.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.UnslothCPOTrainer.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.UnslothDPOTrainer.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.UnslothDistillationTrainer.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.UnslothGKDTrainer.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.UnslothGOLDTrainer.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.UnslothGRPOTrainer.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.UnslothKTOTrainer.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.UnslothORPOTrainer.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.UnslothOnlineDPOTrainer.py +0 -0
- training/unsloth_compiled_cache/.locks/.lock.UnslothRLOOTrainer.py +0 -0
- 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
|