ege-g's picture
Improved calibration (strict-format slice), modelopt 0.45, vLLM 0.24 validation, plugin fixes (adjust_request scoping, extract_content_ids, indentation), full reproducibility bundle (pinned revision, env freeze, calibration file), LICENSE
7484310 verified
Raw
History Blame Contribute Delete
10.7 kB
"""
Build the Japanese-heavy conversational calibration set
(calib_conversational.jsonl). An English-only UltraChat draw of the same
size is also written as a byproduct (calib_en_ultrachat.jsonl).
Mix (Section 6 of the project plan), target 512 samples total:
- 40% JA multi-turn instruction/chat -> llm-jp/oasst2-33k-ja
- 20% JA knowledge/long-form -> JA Wikipedia passages, instruction-wrapped
- 15% JA reasoning/math -> Kendamarron/magpie-japanese-math-instruction-17k
- 10% Code (JA-instructed) -> ronantakizawa/python-code-instructions-japanese
- 15% English chat -> HuggingFaceH4/ultrachat_200k
Every sample is stored as a `messages` list (chat format) and later fed
through `tokenizer.apply_chat_template(messages, add_generation_prompt=False)`
at PTQ time, so calibration activations see the same role tokens /
`<|...|>` specials / structure the model sees in real deployment.
IMPORTANT: the Wikipedia articles used here for the knowledge/long-form slice
must stay disjoint from the articles used in perplexity_ja.py's held-out set
(recorded in results/*/perplexity_ja.json `corpus.article_ids`). The held-out
set consumed articles from the very start of the stream (skip 0, ~50 articles);
we skip --wiki-skip-articles (default 5000) before collecting, guaranteeing
disjointness with two orders of magnitude of margin.
The EN-only set is drawn from the SAME single UltraChat pass as the
main mix's en_chat slice and split by index, so the two are disjoint by
construction (no skip heuristics).
"""
import argparse
import json
import random
from datasets import load_dataset
from transformers import AutoTokenizer
SEED = 1234
TARGET_TOTAL = 512
MAX_TOKENS = 4096
MIX = {
"ja_chat": 0.40,
"ja_knowledge": 0.20,
"ja_math": 0.15,
"code": 0.10,
"en_chat": 0.15,
}
def iter_ja_chat(rng):
# oasst2-33k-ja alone fills the ja_chat pool target, so it is the sole
# source actually drawn from (see the datasheet).
sources = [
("llm-jp/oasst2-33k-ja", "apache-2.0"),
]
for name, license_ in sources:
ds = load_dataset(name, split="train", streaming=True)
for ex in ds:
convo = ex["conversations"]
if len(convo) < 2:
continue
yield {
"messages": [{"role": m["role"], "content": m["content"]} for m in convo],
"source": name,
"license": license_,
}
def iter_ja_knowledge(skip_articles: int):
ds = load_dataset("wikimedia/wikipedia", "20231101.ja", split="train", streaming=True)
it = iter(ds)
for _ in range(skip_articles):
next(it)
prompts = [
"次のトピックについて、知っていることを詳しく説明してください:{title}",
"「{title}」について解説してください。",
"次の見出し語を要約・解説する文章を書いてください:{title}",
]
i = 0
for ex in it:
text = ex["text"].strip()
if len(text) < 800:
continue
prompt = prompts[i % len(prompts)].format(title=ex["title"])
yield {
"messages": [
{"role": "user", "content": prompt},
{"role": "assistant", "content": text[:3000]},
],
"source": f"wikimedia/wikipedia:20231101.ja#{ex['id']}",
"license": "cc-by-sa-4.0",
}
i += 1
def iter_ja_math():
ds = load_dataset(
"Kendamarron/magpie-japanese-math-instruction-17k-qwen2.5-bakeneko-32b-instruct",
split="train",
streaming=True,
)
for ex in ds:
if not ex.get("instruction") or not ex.get("output"):
continue
yield {
"messages": [
{"role": "user", "content": ex["instruction"]},
{"role": "assistant", "content": ex["output"]},
],
"source": "Kendamarron/magpie-japanese-math-instruction-17k",
"license": "apache-2.0",
}
def iter_code():
ds = load_dataset("ronantakizawa/python-code-instructions-japanese", split="train", streaming=True)
for ex in ds:
instruction = ex.get("instruction", "")
input_ = ex.get("input", "")
output = ex.get("output", "")
if not instruction or not output:
continue
user_content = instruction if not input_ else f"{instruction}\n\n{input_}"
yield {
"messages": [
{"role": "user", "content": user_content},
{"role": "assistant", "content": output},
],
"source": "ronantakizawa/python-code-instructions-japanese",
"license": "mit",
}
def iter_en_chat():
ds = load_dataset("HuggingFaceH4/ultrachat_200k", split="train_sft", streaming=True)
for ex in ds:
messages = ex.get("messages", [])
if len(messages) < 2:
continue
yield {
"messages": [{"role": m["role"], "content": m["content"]} for m in messages],
"source": "HuggingFaceH4/ultrachat_200k",
"license": "mit",
}
def collect(gen, tokenizer, count, rng, max_tokens=MAX_TOKENS, pool_multiplier=4, label=""):
"""Pull samples from a generator, keep ones that fit the token budget,
then randomly downsample to `count` for an unbiased draw from the pool."""
pool = []
pool_target = count * pool_multiplier
for sample in gen:
# Template to a string, then encode — apply_chat_template(tokenize=True)'s
# return type varies across transformers versions (list of ids vs
# BatchEncoding, whose len() is its dict key count and silently
# broke this filter on 5.5.x). This two-step form is unambiguous.
templated = tokenizer.apply_chat_template(
sample["messages"], add_generation_prompt=False, tokenize=False
)
n_tokens = len(tokenizer.encode(templated, add_special_tokens=False))
if n_tokens > max_tokens or n_tokens < 8:
continue
sample["num_tokens"] = n_tokens
pool.append(sample)
if len(pool) % 100 == 0:
print(f" [{label}] pool {len(pool)}/{pool_target}", flush=True)
if len(pool) >= pool_target:
break
rng.shuffle(pool)
return pool[:count]
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--tokenizer", default="./models/llm-jp-4-8b-instruct")
parser.add_argument("--output-dir", default="data/calib")
parser.add_argument("--total", type=int, default=TARGET_TOTAL)
parser.add_argument(
"--wiki-skip-articles",
type=int,
default=5000,
help="Skip this many leading JA-Wikipedia articles to stay well clear "
"of perplexity_ja.py's held-out set (which consumes articles from "
"the very start of the stream).",
)
args = parser.parse_args()
rng = random.Random(SEED)
tokenizer = AutoTokenizer.from_pretrained(args.tokenizer, trust_remote_code=True)
counts = {k: round(v * args.total) for k, v in MIX.items()}
# Fix up rounding so counts sum exactly to args.total.
diff = args.total - sum(counts.values())
counts[max(counts, key=counts.get)] += diff
print("Target mix:", counts)
all_samples = []
datasheet = {"seed": SEED, "total": args.total, "max_tokens": MAX_TOKENS, "mix": {}}
print("Collecting ja_chat...", flush=True)
ja_chat = collect(iter_ja_chat(rng), tokenizer, counts["ja_chat"], rng, label="ja_chat")
all_samples += ja_chat
print("Collecting ja_knowledge...", flush=True)
ja_knowledge = collect(
iter_ja_knowledge(args.wiki_skip_articles),
tokenizer,
counts["ja_knowledge"],
rng,
label="ja_knowledge",
)
all_samples += ja_knowledge
print("Collecting ja_math...", flush=True)
ja_math = collect(iter_ja_math(), tokenizer, counts["ja_math"], rng, label="ja_math")
all_samples += ja_math
print("Collecting code...", flush=True)
code = collect(iter_code(), tokenizer, counts["code"], rng, label="code")
all_samples += code
# Collect both EN slices in one filtered pass over one UltraChat
# stream, then split by index — disjoint by construction.
print("Collecting en_chat (single pass)...", flush=True)
en_all = collect(
iter_en_chat(),
tokenizer,
counts["en_chat"] + args.total,
rng,
pool_multiplier=2,
label="en_chat",
)
en_chat = en_all[: counts["en_chat"]]
en_only_extra = en_all[counts["en_chat"] :]
all_samples += en_chat
for name, samples in [
("ja_chat", ja_chat),
("ja_knowledge", ja_knowledge),
("ja_math", ja_math),
("code", code),
("en_chat", en_chat),
]:
sources = sorted(set(s["source"].split("#")[0] for s in samples))
licenses = sorted(set(s["license"] for s in samples))
datasheet["mix"][name] = {
"count": len(samples),
"target": counts[name],
"sources": sources,
"licenses": licenses,
"mean_tokens": sum(s["num_tokens"] for s in samples) / max(len(samples), 1),
}
datasheet["en_only_extra"] = {
"count": len(en_only_extra),
"target": args.total,
"sources": ["HuggingFaceH4/ultrachat_200k"],
"licenses": ["mit"],
"mean_tokens": sum(s["num_tokens"] for s in en_only_extra) / max(len(en_only_extra), 1),
"note": "drawn from the same single-pass pool as the main mix's "
"en_chat slice and split by index — disjoint by construction",
}
rng.shuffle(all_samples)
out_path = f"{args.output_dir}/calib_conversational.jsonl"
with open(out_path, "w") as f:
for s in all_samples:
f.write(json.dumps({"messages": s["messages"], "source": s["source"]}, ensure_ascii=False) + "\n")
datasheet["actual_total"] = len(all_samples)
with open(f"{args.output_dir}/calibration_datasheet_conversational.json", "w") as f:
json.dump(datasheet, f, ensure_ascii=False, indent=2)
print(f"\nWrote {len(all_samples)} samples to {out_path}", flush=True)
print(json.dumps(datasheet, ensure_ascii=False, indent=2), flush=True)
en_out_path = f"{args.output_dir}/calib_en_ultrachat.jsonl"
with open(en_out_path, "w") as f:
for s in en_only_extra:
f.write(json.dumps({"messages": s["messages"], "source": s["source"]}, ensure_ascii=False) + "\n")
print(f"Wrote {len(en_only_extra)} EN-only samples to {en_out_path}", flush=True)
if __name__ == "__main__":
main()