Mehdi commited on
Commit
8087133
Β·
1 Parent(s): 7a3f6e0

feat: Well-Tuned + Llama Champion groundwork

Browse files

- finetune/train_modal.py: QLoRA fine-tune of MiniCPM4-8B on SQuAD pairs
formatted with the production prompt, merged and pushed to the Hub
- finetune/convert_gguf_modal.py: GGUF f16 + Q4_K_M conversion of the
fine-tuned model, pushed to a -GGUF repo
- model/llm.py: llama.cpp runtime backend (LlamaCppLLM) selected via
PAPERPROF_RUNTIME=llamacpp, transformers stays the default

finetune/convert_gguf_modal.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ finetune/convert_gguf_modal.py β€” Convert the fine-tuned model to GGUF for llama.cpp.
3
+
4
+ Pulls build-small-hackathon/MiniCPM4-8B-PaperProf, converts to GGUF f16 with
5
+ llama.cpp's convert script, quantizes to Q4_K_M, and pushes both files to
6
+ build-small-hackathon/MiniCPM4-8B-PaperProf-GGUF.
7
+
8
+ Run (after the fine-tune has been pushed):
9
+ modal run finetune/convert_gguf_modal.py
10
+ """
11
+
12
+ import modal
13
+
14
+ SRC_REPO = "build-small-hackathon/MiniCPM4-8B-PaperProf"
15
+ GGUF_REPO = "build-small-hackathon/MiniCPM4-8B-PaperProf-GGUF"
16
+
17
+ app = modal.App("paperprof-gguf")
18
+
19
+ image = (
20
+ modal.Image.debian_slim(python_version="3.12")
21
+ .apt_install("git", "cmake", "build-essential", "curl")
22
+ .run_commands(
23
+ "git clone --depth 1 https://github.com/ggml-org/llama.cpp /llama.cpp",
24
+ "cmake -S /llama.cpp -B /llama.cpp/build -DGGML_NATIVE=OFF",
25
+ "cmake --build /llama.cpp/build --target llama-quantize -j",
26
+ )
27
+ .pip_install(
28
+ "torch==2.6.0",
29
+ "transformers==4.57.1",
30
+ "sentencepiece",
31
+ "huggingface_hub",
32
+ )
33
+ .run_commands("pip install -r /llama.cpp/requirements/requirements-convert_hf_to_gguf.txt")
34
+ )
35
+
36
+ MODEL_CARD = f"""---
37
+ license: apache-2.0
38
+ base_model: {SRC_REPO}
39
+ tags:
40
+ - gguf
41
+ - llama.cpp
42
+ - question-generation
43
+ - education
44
+ - paperprof
45
+ language:
46
+ - en
47
+ ---
48
+
49
+ # MiniCPM4-8B-PaperProf-GGUF
50
+
51
+ GGUF quantizations of [{SRC_REPO}](https://huggingface.co/{SRC_REPO}),
52
+ the fine-tuned exam-question generator behind
53
+ [PaperProf](https://huggingface.co/spaces/build-small-hackathon/PaperProf).
54
+
55
+ | File | Quant | Size | Use |
56
+ |---|---|---|---|
57
+ | `minicpm4-8b-paperprof-Q4_K_M.gguf` | Q4_K_M | ~4.9 GB | recommended, used by the Space |
58
+ | `minicpm4-8b-paperprof-f16.gguf` | F16 | ~16 GB | full precision reference |
59
+
60
+ ## Usage with llama.cpp
61
+
62
+ ```bash
63
+ llama-cli -hf {GGUF_REPO} -p "your prompt"
64
+ ```
65
+
66
+ ## Usage with llama-cpp-python
67
+
68
+ ```python
69
+ from llama_cpp import Llama
70
+ llm = Llama.from_pretrained("{GGUF_REPO}", filename="*Q4_K_M.gguf", n_gpu_layers=-1)
71
+ ```
72
+
73
+ Built for the Build Small Hackathon, June 2026, by Team PaperProf (EPITA).
74
+ """
75
+
76
+
77
+ @app.function(
78
+ image=image,
79
+ timeout=2 * 60 * 60,
80
+ cpu=8,
81
+ memory=65536,
82
+ ephemeral_disk=120 * 1024,
83
+ secrets=[modal.Secret.from_name("paperprof-hf")],
84
+ )
85
+ def convert():
86
+ import os
87
+ import subprocess
88
+ from huggingface_hub import snapshot_download, HfApi
89
+
90
+ token = os.environ["HF_TOKEN"]
91
+ api = HfApi(token=token)
92
+
93
+ print(f"[pull] downloading {SRC_REPO}…")
94
+ src = snapshot_download(SRC_REPO, token=token, local_dir="/tmp/src")
95
+
96
+ f16 = "/tmp/minicpm4-8b-paperprof-f16.gguf"
97
+ q4 = "/tmp/minicpm4-8b-paperprof-Q4_K_M.gguf"
98
+
99
+ print("[convert] HF β†’ GGUF f16…")
100
+ subprocess.run(
101
+ ["python", "/llama.cpp/convert_hf_to_gguf.py", src, "--outfile", f16, "--outtype", "f16"],
102
+ check=True,
103
+ )
104
+
105
+ print("[quantize] f16 β†’ Q4_K_M…")
106
+ subprocess.run(
107
+ ["/llama.cpp/build/bin/llama-quantize", f16, q4, "Q4_K_M"],
108
+ check=True,
109
+ )
110
+
111
+ print(f"[push] uploading to {GGUF_REPO}…")
112
+ api.create_repo(GGUF_REPO, exist_ok=True)
113
+ api.upload_file(path_or_fileobj=q4, path_in_repo=os.path.basename(q4), repo_id=GGUF_REPO)
114
+ api.upload_file(path_or_fileobj=f16, path_in_repo=os.path.basename(f16), repo_id=GGUF_REPO)
115
+ api.upload_file(path_or_fileobj=MODEL_CARD.encode(), path_in_repo="README.md", repo_id=GGUF_REPO)
116
+ print(f"[done] https://huggingface.co/{GGUF_REPO}")
117
+
118
+
119
+ @app.local_entrypoint()
120
+ def main():
121
+ convert.remote()
finetune/train_modal.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ finetune/train_modal.py β€” QLoRA fine-tune of MiniCPM4-8B for PaperProf.
3
+
4
+ Task: exam-question generation. The training data is SQuAD passages and
5
+ questions reformatted into the EXACT production prompt used by
6
+ core/questioner.py, so the model learns PaperProf's task, not generic QA.
7
+
8
+ Run:
9
+ modal run finetune/train_modal.py
10
+
11
+ Output:
12
+ Merged bf16 model pushed to hf.co/build-small-hackathon/MiniCPM4-8B-PaperProf
13
+ """
14
+
15
+ import modal
16
+
17
+ APP_NAME = "paperprof-finetune"
18
+ BASE_MODEL = "openbmb/MiniCPM4-8B"
19
+ HUB_REPO = "build-small-hackathon/MiniCPM4-8B-PaperProf"
20
+ N_SAMPLES = 3000
21
+ MAX_LEN = 1024
22
+
23
+ app = modal.App(APP_NAME)
24
+
25
+ image = (
26
+ modal.Image.debian_slim(python_version="3.12")
27
+ .pip_install(
28
+ "torch==2.6.0",
29
+ "transformers==4.57.1",
30
+ "datasets==3.2.0",
31
+ "peft==0.14.0",
32
+ "bitsandbytes==0.46.0",
33
+ "accelerate>=1.8.0",
34
+ "huggingface_hub",
35
+ "sentencepiece",
36
+ )
37
+ )
38
+
39
+ # Mirror of core/questioner.py _PROMPT_TEMPLATE (Normal difficulty)
40
+ PROMPT_TEMPLATE = """\
41
+ You are a university professor creating exam questions.
42
+ Given the following excerpt from a course, write ONE focused question.
43
+
44
+ Difficulty β€” Ask for conceptual understanding (Explain X. Why does X happen?).
45
+
46
+ Rules:
47
+ - ONE question only, on ONE concept
48
+ - Maximum 25 words
49
+ - No sub-questions, no "and", no compound questions
50
+ - IMPORTANT: Always write the question in English, even if the source text is in another language
51
+ - Output only the question, nothing else
52
+
53
+ Excerpt:
54
+ {chunk}
55
+
56
+ Question:"""
57
+
58
+ MODEL_CARD = f"""---
59
+ license: apache-2.0
60
+ base_model: {BASE_MODEL}
61
+ tags:
62
+ - question-generation
63
+ - education
64
+ - lora
65
+ - paperprof
66
+ datasets:
67
+ - squad
68
+ language:
69
+ - en
70
+ ---
71
+
72
+ # MiniCPM4-8B-PaperProf
73
+
74
+ Fine-tuned from [{BASE_MODEL}](https://huggingface.co/{BASE_MODEL}) for
75
+ **exam-question generation** in [PaperProf](https://huggingface.co/spaces/build-small-hackathon/PaperProf),
76
+ an AI study buddy that turns course PDFs into interactive quiz sessions.
77
+
78
+ ## Training
79
+
80
+ - **Method:** QLoRA (4-bit NF4, r=16, alpha=32, all-linear targets), merged to bf16
81
+ - **Data:** {N_SAMPLES} SQuAD passage/question pairs reformatted into PaperProf's
82
+ production prompt template, so the model is optimized for the exact task it
83
+ serves: one focused, concise exam question per course excerpt.
84
+ - **Epochs:** 1, lr 2e-4 cosine, bf16 compute
85
+
86
+ ## Usage
87
+
88
+ Drop-in replacement for the base model:
89
+
90
+ ```python
91
+ from transformers import AutoTokenizer, AutoModelForCausalLM
92
+ tok = AutoTokenizer.from_pretrained("{HUB_REPO}", trust_remote_code=True)
93
+ model = AutoModelForCausalLM.from_pretrained("{HUB_REPO}", trust_remote_code=True, torch_dtype="bfloat16")
94
+ ```
95
+
96
+ Built for the Build Small Hackathon, June 2026, by Team PaperProf (EPITA).
97
+ """
98
+
99
+
100
+ @app.function(
101
+ image=image,
102
+ gpu="A100-80GB",
103
+ timeout=3 * 60 * 60,
104
+ secrets=[modal.Secret.from_name("paperprof-hf")],
105
+ )
106
+ def train():
107
+ import os
108
+ import torch
109
+ from datasets import load_dataset
110
+ from transformers import (
111
+ AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig,
112
+ Trainer, TrainingArguments, DataCollatorForLanguageModeling,
113
+ )
114
+ from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
115
+ from huggingface_hub import HfApi
116
+
117
+ token = os.environ["HF_TOKEN"]
118
+
119
+ # ── Data: SQuAD β†’ production prompt format ────────────────────────────
120
+ print("[data] loading SQuAD…")
121
+ ds = load_dataset("squad", split="train")
122
+ seen, rows = set(), []
123
+ for ex in ds:
124
+ ctx = ex["context"].strip()
125
+ if not (300 <= len(ctx) <= 1500) or ctx in seen:
126
+ continue
127
+ seen.add(ctx)
128
+ q = ex["question"].strip()
129
+ if not q.endswith("?") or len(q.split()) > 25:
130
+ continue
131
+ rows.append({"chunk": ctx, "question": q})
132
+ if len(rows) >= N_SAMPLES:
133
+ break
134
+ print(f"[data] {len(rows)} training pairs")
135
+
136
+ tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL, trust_remote_code=True, token=token)
137
+ if tokenizer.pad_token is None:
138
+ tokenizer.pad_token = tokenizer.eos_token
139
+
140
+ def to_text(row):
141
+ messages = [
142
+ {"role": "user", "content": PROMPT_TEMPLATE.format(chunk=row["chunk"])},
143
+ {"role": "assistant", "content": row["question"]},
144
+ ]
145
+ return tokenizer.apply_chat_template(messages, tokenize=False)
146
+
147
+ texts = [to_text(r) for r in rows]
148
+
149
+ def tokenize(batch):
150
+ return tokenizer(batch["text"], truncation=True, max_length=MAX_LEN, padding=False)
151
+
152
+ from datasets import Dataset
153
+ train_ds = Dataset.from_dict({"text": texts}).map(
154
+ tokenize, batched=True, remove_columns=["text"]
155
+ )
156
+
157
+ # ── Model: 4-bit base + LoRA ──────────────────────────────────────────
158
+ print("[model] loading base in 4-bit…")
159
+ bnb = BitsAndBytesConfig(
160
+ load_in_4bit=True,
161
+ bnb_4bit_quant_type="nf4",
162
+ bnb_4bit_compute_dtype=torch.bfloat16,
163
+ bnb_4bit_use_double_quant=True,
164
+ )
165
+ model = AutoModelForCausalLM.from_pretrained(
166
+ BASE_MODEL, quantization_config=bnb, device_map="auto",
167
+ trust_remote_code=True, token=token,
168
+ )
169
+ model = prepare_model_for_kbit_training(model)
170
+ lora = LoraConfig(
171
+ r=16, lora_alpha=32, lora_dropout=0.05,
172
+ target_modules="all-linear", task_type="CAUSAL_LM",
173
+ )
174
+ model = get_peft_model(model, lora)
175
+ model.print_trainable_parameters()
176
+
177
+ # ── Train ─────────────────────────────────────────────────────────────
178
+ args = TrainingArguments(
179
+ output_dir="/tmp/out",
180
+ num_train_epochs=1,
181
+ per_device_train_batch_size=4,
182
+ gradient_accumulation_steps=4,
183
+ learning_rate=2e-4,
184
+ lr_scheduler_type="cosine",
185
+ warmup_ratio=0.03,
186
+ bf16=True,
187
+ logging_steps=10,
188
+ save_strategy="no",
189
+ report_to=[],
190
+ gradient_checkpointing=True,
191
+ )
192
+ collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False)
193
+ trainer = Trainer(model=model, args=args, train_dataset=train_ds, data_collator=collator)
194
+ print("[train] starting…")
195
+ trainer.train()
196
+
197
+ # ── Merge LoRA into bf16 base and push ───────────────────────────────
198
+ print("[merge] reloading base in bf16 and merging adapter…")
199
+ model.save_pretrained("/tmp/adapter")
200
+ del model, trainer
201
+ torch.cuda.empty_cache()
202
+
203
+ from peft import PeftModel
204
+ base = AutoModelForCausalLM.from_pretrained(
205
+ BASE_MODEL, torch_dtype=torch.bfloat16, device_map="auto",
206
+ trust_remote_code=True, token=token,
207
+ )
208
+ merged = PeftModel.from_pretrained(base, "/tmp/adapter")
209
+ merged = merged.merge_and_unload()
210
+
211
+ print(f"[push] uploading to {HUB_REPO}…")
212
+ merged.push_to_hub(HUB_REPO, token=token, private=False)
213
+ tokenizer.push_to_hub(HUB_REPO, token=token)
214
+ HfApi(token=token).upload_file(
215
+ path_or_fileobj=MODEL_CARD.encode(),
216
+ path_in_repo="README.md",
217
+ repo_id=HUB_REPO,
218
+ )
219
+ print(f"[done] https://huggingface.co/{HUB_REPO}")
220
+
221
+
222
+ @app.local_entrypoint()
223
+ def main():
224
+ train.remote()
model/llm.py CHANGED
@@ -12,9 +12,13 @@ Model choice:
12
  Requires transformers >= 4.50.0.
13
 
14
  Environment variables:
15
- PAPERPROF_MODEL Override the default model ID (e.g. "openbmb/MiniCPM3-4B"
16
- for a smaller fallback during local testing).
17
- PAPERPROF_DEVICE "cuda", "mps", or "cpu" (default: auto-detected).
 
 
 
 
18
 
19
  Public API:
20
  get_llm() -> LLM β€” return the singleton instance
@@ -101,9 +105,41 @@ class LLM:
101
  return output[0]["generated_text"]
102
 
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  @lru_cache(maxsize=1)
105
- def get_llm() -> LLM:
106
  """Return the singleton LLM, loading the model on first call."""
 
 
 
 
107
  model_id = os.environ.get("PAPERPROF_MODEL", DEFAULT_MODEL_ID)
108
  device = os.environ.get("PAPERPROF_DEVICE", "auto")
109
  return LLM(model_id=model_id, device=device)
 
12
  Requires transformers >= 4.50.0.
13
 
14
  Environment variables:
15
+ PAPERPROF_MODEL Override the default model ID (e.g. "openbmb/MiniCPM3-4B"
16
+ for a smaller fallback during local testing).
17
+ PAPERPROF_DEVICE "cuda", "mps", or "cpu" (default: auto-detected).
18
+ PAPERPROF_RUNTIME "transformers" (default) or "llamacpp" to run the GGUF
19
+ model through the llama.cpp runtime instead.
20
+ PAPERPROF_GGUF_REPO GGUF repo for the llamacpp runtime
21
+ (default: build-small-hackathon/MiniCPM4-8B-PaperProf-GGUF).
22
 
23
  Public API:
24
  get_llm() -> LLM β€” return the singleton instance
 
105
  return output[0]["generated_text"]
106
 
107
 
108
+ DEFAULT_GGUF_REPO = "build-small-hackathon/MiniCPM4-8B-PaperProf-GGUF"
109
+
110
+
111
+ class LlamaCppLLM:
112
+ """Same .generate() interface as LLM, backed by the llama.cpp runtime."""
113
+
114
+ def __init__(self, repo_id: str):
115
+ from llama_cpp import Llama
116
+
117
+ n_gpu_layers = -1 if torch.cuda.is_available() else 0
118
+ print(f"[LlamaCppLLM] loading {repo_id} (n_gpu_layers={n_gpu_layers})")
119
+ self._llm = Llama.from_pretrained(
120
+ repo_id=repo_id,
121
+ filename="*Q4_K_M.gguf",
122
+ n_gpu_layers=n_gpu_layers,
123
+ n_ctx=4096,
124
+ verbose=False,
125
+ )
126
+
127
+ def generate(self, prompt: str, max_new_tokens: int = DEFAULT_MAX_NEW_TOKENS) -> str:
128
+ out = self._llm.create_chat_completion(
129
+ messages=[{"role": "user", "content": prompt}],
130
+ max_tokens=max_new_tokens,
131
+ temperature=0.0,
132
+ )
133
+ return out["choices"][0]["message"]["content"]
134
+
135
+
136
  @lru_cache(maxsize=1)
137
+ def get_llm():
138
  """Return the singleton LLM, loading the model on first call."""
139
+ runtime = os.environ.get("PAPERPROF_RUNTIME", "transformers").lower()
140
+ if runtime == "llamacpp":
141
+ repo_id = os.environ.get("PAPERPROF_GGUF_REPO", DEFAULT_GGUF_REPO)
142
+ return LlamaCppLLM(repo_id=repo_id)
143
  model_id = os.environ.get("PAPERPROF_MODEL", DEFAULT_MODEL_ID)
144
  device = os.environ.get("PAPERPROF_DEVICE", "auto")
145
  return LLM(model_id=model_id, device=device)