Astheral / v2 /train.py
SEE7's picture
Finish v2 remote inference and data-license packaging
54e7d65 verified
Raw History Blame Contribute Delete
12.7 kB
"""Astheral v2: remote L4 QLoRA training and measured release."""
import os,json,time,gc,hashlib,shutil,math,random,urllib.request,collections
from pathlib import Path
import torch
from torch.utils.data import Dataset
from transformers import AutoModelForCausalLM,AutoTokenizer,BitsAndBytesConfig,Trainer,TrainingArguments,TrainerCallback,set_seed
from peft import LoraConfig,get_peft_model,prepare_model_for_kbit_training,PeftModel
from huggingface_hub import HfApi,hf_hub_download
from evaluate import CASES,check,summary
ROOT=Path(__file__).resolve().parent
OUT=Path("/tmp/astheral-v2-output");OUT.mkdir(exist_ok=True)
REPO="SEE7/Astheral";BASE="NousResearch/DeepHermes-3-Llama-3-8B-Preview";REV="53d902c7dcfa4c749a3b455149064f82af2e2549"
api=HfApi();start=time.monotonic();set_seed(20261113)
assert torch.cuda.is_available(),"Remote CUDA GPU required"
assert "L4" in torch.cuda.get_device_name(0),"This job is provisioned for remote L4"
print("REMOTE_HARDWARE",torch.cuda.get_device_name(0),flush=True)
settings=json.loads((ROOT/"settings.json").read_text())
tok=AutoTokenizer.from_pretrained(BASE,revision=REV);tok.pad_token_id=128004
def messages(prompt,mode):
return [{"role":"system","content":settings["system"]+("\n"+settings["thinking"] if mode=="thinking" else "")},{"role":"user","content":prompt}]
class Samples(Dataset):
def __init__(self,rows):
self.items=[];self.kept=[];self.skipped=0
evalprompts={c["prompt"] for c in CASES}
for r in rows:
if any(m["role"]=="user" and m["content"] in evalprompts for m in r["messages"]):raise RuntimeError("Evaluation overlap")
prompt=tok.apply_chat_template(r["messages"][:-1],tokenize=True,add_generation_prompt=True)
full=tok.apply_chat_template(r["messages"],tokenize=True,add_generation_prompt=False)
if len(full)>1280:self.skipped+=1;continue
assert full[:len(prompt)]==prompt
self.items.append({"input_ids":full,"labels":[-100]*len(prompt)+full[len(prompt):]})
self.kept.append(r)
def __len__(self):return len(self.items)
def __getitem__(self,i):return self.items[i]
def collate(batch):
size=max(len(x["input_ids"]) for x in batch)
return {k:torch.tensor([x[k]+[fill]*(size-len(x[k])) for x in batch]) for k,fill in [("input_ids",128004),("labels",-100)]}|{"attention_mask":torch.tensor([[1]*len(x["input_ids"])+[0]*(size-len(x["input_ids"])) for x in batch])}
records=[json.loads(s) for s in (ROOT/"curriculum.jsonl").read_text().splitlines()]
random.Random(20261113).shuffle(records)
# Loss-validation conversations are disjoint from training.
valid=Samples(records[:64]);train=Samples(records[64:])
assert len(train)>1800
(OUT/"train.jsonl").write_text("\n".join(json.dumps(r) for r in train.kept)+"\n")
(OUT/"validation.jsonl").write_text("\n".join(json.dumps(r) for r in valid.kept)+"\n")
print("DATA_READY",len(train),len(valid),"skipped_long",train.skipped,flush=True)
quant=BitsAndBytesConfig(load_in_4bit=True,bnb_4bit_quant_type="nf4",bnb_4bit_use_double_quant=True,bnb_4bit_compute_dtype=torch.bfloat16)
model=AutoModelForCausalLM.from_pretrained(BASE,revision=REV,torch_dtype=torch.bfloat16,device_map={"":"cuda:0"},attn_implementation="sdpa")
@torch.inference_mode()
def generate(tag,cases=CASES,batch_size=4):
model.eval();tok.padding_side="left";results=[]
for mode in ["instruct","thinking"]:
selected=[c for c in cases if c["mode"]==mode]
for i in range(0,len(selected),batch_size):
group=selected[i:i+batch_size];set_seed(667+i)
texts=[tok.apply_chat_template(messages(c["prompt"],mode),tokenize=False,add_generation_prompt=True) for c in group]
inputs=tok(texts,padding=True,return_tensors="pt").to("cuda")
out=model.generate(**inputs,max_new_tokens=1024 if mode=="thinking" else 384,do_sample=False,pad_token_id=128004,eos_token_id=128009)
for c,o in zip(group,out):
answer=tok.decode(o[inputs["input_ids"].shape[1]:],skip_special_tokens=True)
results.append({**c,"output":answer,"pass":check(c,answer)})
print("EVAL",tag,c["id"],results[-1]["pass"],flush=True)
tok.padding_side="right"
(OUT/(tag+"_outputs.json")).write_text(json.dumps(results,indent=2))
print("SCORE",tag,json.dumps(summary(results)),flush=True)
return results
baseline=generate("base",batch_size=2)
del model;gc.collect();torch.cuda.empty_cache()
model=AutoModelForCausalLM.from_pretrained(BASE,revision=REV,quantization_config=quant,torch_dtype=torch.bfloat16,device_map={"":"cuda:0"},attn_implementation="sdpa")
model=prepare_model_for_kbit_training(model,use_gradient_checkpointing=True,gradient_checkpointing_kwargs={"use_reentrant":False})
model=get_peft_model(model,LoraConfig(r=32,lora_alpha=64,lora_dropout=0.05,target_modules="all-linear",task_type="CAUSAL_LM",bias="none"))
model.config.use_cache=False
trainable=sum(p.numel() for p in model.parameters() if p.requires_grad)
model.print_trainable_parameters()
class Budget(TrainerCallback):
def on_step_end(self,args,state,control,**kwargs):
if time.monotonic()-start>7200:control.should_training_stop=True
return control
args=TrainingArguments(output_dir="/tmp/astheral-v2-checkpoints",num_train_epochs=1.5,per_device_train_batch_size=1,per_device_eval_batch_size=1,gradient_accumulation_steps=8,learning_rate=4e-5,lr_scheduler_type="cosine",warmup_ratio=0.05,weight_decay=0.01,bf16=True,gradient_checkpointing=True,gradient_checkpointing_kwargs={"use_reentrant":False},optim="paged_adamw_8bit",logging_steps=10,save_strategy="no",report_to="none",remove_unused_columns=False,seed=20261113,data_seed=20261113,max_grad_norm=0.5,dataloader_num_workers=0)
trainer=Trainer(model=model,args=args,train_dataset=train,eval_dataset=valid,data_collator=collate,callbacks=[Budget()])
before=trainer.evaluate();result=trainer.train();after=trainer.evaluate()
model.config.use_cache=True;model.gradient_checkpointing_disable()
candidate=generate("astheral")
def gate(outputs):
s=summary(outputs)
return s["regression"]["passed"]==s["regression"]["total"] and s["fresh"]["passed"]>=max(31,summary(baseline)["fresh"]["passed"]) and s["number"]["passed"]>=14 and s["code"]["passed"]>=5 and s["exact"]["passed"]+s["json"]["passed"]>=12
quality=gate(candidate)
# Save the adapter and reports before any merge, regardless of evaluation result.
adapter=OUT/"adapter";model.save_pretrained(adapter,safe_serialization=True);tok.save_pretrained(adapter)
metrics={"version":"2","base_model":BASE,"base_revision":REV,"training_examples":len(train),"validation_examples":len(valid),"skipped_long_examples":train.skipped,"trainable_parameters":trainable,"rank":32,"target_modules":"all-linear","quantization":"NF4 QLoRA; BF16 adapter computation","optimizer_steps":trainer.state.global_step,"epochs":trainer.state.epoch,"learning_rate":4e-5,"validation_loss_before":before["eval_loss"],"validation_loss_after":after["eval_loss"],"training":result.metrics,"base_scores":summary(baseline),"astheral_scores":summary(candidate),"quality_gate_passed":quality,"hardware":torch.cuda.get_device_name(0),"max_gpu_memory_allocated_bytes":torch.cuda.max_memory_allocated(),"elapsed_seconds":time.monotonic()-start,"generation":{"sampling":False,"instruct_max_new_tokens":384,"thinking_max_new_tokens":1024},"evaluation_design":"36 frozen fresh checks, two known regression cases, six review-only samples. Same settings for base and fine-tune."}
(OUT/"evaluation.json").write_text(json.dumps(metrics,indent=2))
(OUT/"trainer_log.json").write_text(json.dumps(trainer.state.log_history,indent=2))
for name in ["train.py","prepare_data.py","evaluate.py","settings.json","requirements.txt","data_sources.json"]:
shutil.copy2(ROOT/name,OUT/name)
api.upload_folder(repo_id=REPO,folder_path=OUT,path_in_repo="v2-results",commit_message="Record v2 trained adapter and complete frozen-suite comparison")
print("QUALITY_GATE",quality,json.dumps(metrics),flush=True)
if not quality:
print("QUANTIZED_GATE_MISSED; checking the final BF16 merge before release decision",flush=True)
# Merge into original BF16 weights, rather than dequantized training weights.
del trainer,model;gc.collect();torch.cuda.empty_cache()
base=AutoModelForCausalLM.from_pretrained(BASE,revision=REV,torch_dtype=torch.bfloat16,device_map={"":"cuda:0"},attn_implementation="sdpa")
model=PeftModel.from_pretrained(base,adapter).merge_and_unload(safe_merge=True)
model.config._name_or_path="Llama-3.1-Astheral"
model.config.astheral_base_model=BASE;model.config.astheral_version="2";model.config.astheral_modes=["instruct","thinking"]
# Check the known failures again after conversion back to BF16.
merged_checks=generate("merged_regressions",[c for c in CASES if c["group"]=="regression"],batch_size=1)
if not all(c["pass"] for c in merged_checks):raise RuntimeError("Merged checkpoint regression check failed")
model.save_pretrained(OUT,safe_serialization=True,max_shard_size="4GB");tok.save_pretrained(OUT)
del model,base;gc.collect();torch.cuda.empty_cache()
model=AutoModelForCausalLM.from_pretrained(OUT,torch_dtype=torch.bfloat16,device_map={"":"cuda:0"},attn_implementation="sdpa")
merged_outputs=generate("astheral_merged",batch_size=2)
if not gate(merged_outputs):raise RuntimeError("Reloaded merged model did not pass release gate")
metrics["merged_scores"]=summary(merged_outputs)
metrics["quality_gate_passed"]=True
metrics["training_source_counts"]=dict(collections.Counter(r["source"] for r in train.kept))
metrics["elapsed_seconds"]=time.monotonic()-start
(OUT/"evaluation.json").write_text(json.dumps(metrics,indent=2))
(OUT/"reload_smoke_test.json").write_text(json.dumps({"loaded_from":"saved merged BF16 checkpoint","regressions_passed":True,"full_suite_gate_passed":True},indent=2))
(OUT/"DATA.md").write_text("# Training data\n\nExact selected examples are in train.jsonl and validation.jsonl. Source revisions, component counts and dataset licenses are recorded in data_sources.json. Original Astheral examples are CC0-1.0. Model weights retain the applicable Llama license.\n")
for name in ["inference.py","LICENSE","UPSTREAM_LLAMA3_LICENSE","NOTICE"]:
shutil.copy2(ROOT/name if (ROOT/name).exists() else Path(hf_hub_download(REPO,name)),OUT/name)
license_urls={"DATA_LICENSE_GSM8K.txt":"https://raw.githubusercontent.com/openai/grade-school-math/master/LICENSE","DATA_LICENSE_APACHE2.txt":"https://www.apache.org/licenses/LICENSE-2.0.txt"}
for name,url in license_urls.items():
(OUT/name).write_bytes((ROOT/name).read_bytes() if (ROOT/name).exists() else urllib.request.urlopen(url,timeout=60).read())
(OUT/"DATA_LICENSE_ODCBY.txt").write_text("Contains information from bigcode/self-oss-instruct-sc2-exec-filter-50k, made available under the ODC Attribution License v1.0: https://opendatacommons.org/licenses/by/1-0/. Source: https://huggingface.co/datasets/bigcode/self-oss-instruct-sc2-exec-filter-50k\n")
(OUT/"NOTICE").write_text((OUT/"NOTICE").read_text()+"\nContains data from HuggingFaceTB/SmolTalk (Apache 2.0), openai/gsm8k (MIT; Copyright (c) 2021 OpenAI), and bigcode/self-oss-instruct-sc2-exec-filter-50k, made available under the ODC Attribution License: https://opendatacommons.org/licenses/by/1-0/. Source revisions and transformations are recorded in data_sources.json and prepare_data.py.\n")
card=(ROOT/"MODEL_CARD.md").read_text()
card+="\n## Measured evaluation\n\n"+json.dumps({"base":metrics["base_scores"],"astheral":metrics["merged_scores"]},indent=2)+"\n\nFull prompts, outputs, code tests and training records are included. The 36 fresh checks cover constrained output, multi-step numerical problems and executable Python functions. The two prior failures are tracked separately as regression checks.\n"
(OUT/"README.md").write_text(card)
manifest={}
for f in OUT.rglob("*"):
if f.is_file():
h=hashlib.sha256()
with f.open("rb") as stream:
for chunk in iter(lambda:stream.read(8388608),b""):h.update(chunk)
manifest[str(f.relative_to(OUT))]={"bytes":f.stat().st_size,"sha256":h.hexdigest()}
(OUT/"artifact_manifest.json").write_text(json.dumps(manifest,indent=2))
# Freeze historical v1 results before updating the release.
api.create_tag(REPO,tag="v1",revision=api.model_info(REPO).sha,exist_ok=True)
api.upload_folder(repo_id=REPO,folder_path=OUT,delete_patterns=["training/*","coding_smoke_test.json"],commit_message="Release Astheral v2 with balanced training, expanded adapters and passing quality gates")
api.create_tag(REPO,tag="v2",revision=api.model_info(REPO).sha,exist_ok=True)
print("PUBLISHED_V2",api.model_info(REPO).sha,flush=True)