MamunAI2 / train.py
almamunkhan's picture
Update train.py
3fe47e8 verified
Raw
History Blame Contribute Delete
19.9 kB
"""
train.py β€” MamunAI LoRA Fine-Tuning (Resumable + GitHub Dataset)
- Downloads dataset at runtime from GitHub (never stored in repo).
- Resumes from existing LoRA adapter on Hugging Face Hub if available.
- Never auto-merges; merge is a separate explicit command.
Owner: Al Mamun Khan
"""
import json
import os
import logging
import shutil
import urllib.request
import torch
from pathlib import Path
from typing import Optional, Callable
from huggingface_hub import login, create_repo, hf_hub_download, list_repo_files, HfApi
from huggingface_hub.utils import RepositoryNotFoundError, EntryNotFoundError
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger("MamunAI-Train")
# ── Constants ─────────────────────────────────────────────────────────────────
BASE_MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct"
HF_REPO = "almamunkhan/MamunAI"
GITHUB_DATASET_URL = "https://raw.githubusercontent.com/hunterking42/Mamun_AI/main/dataset.jsonl"
RUNTIME_DATASET_DIR = "./runtime_datasets"
RUNTIME_DATASET = f"{RUNTIME_DATASET_DIR}/dataset.jsonl"
OUTPUT_DIR = "./lora_adapter"
MERGED_DIR = "./merged_model"
MAX_SEQ_LENGTH = 256
LORA_RANK = 8
LORA_ALPHA = 16
LORA_DROPOUT = 0.05
LEARNING_RATE = 3e-4
NUM_EPOCHS = 1
BATCH_SIZE = 8
GRAD_ACCUM = 4
WARMUP_STEPS = 5
SAVE_STEPS = 100
LORA_TARGET_MODULES = [
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
]
# ── System prompt ─────────────────────────────────────────────────────────────
SYSTEM_PROMPT = (
"You are MamunAI, a personal AI assistant created by Al Mamun Khan "
"from Bangladesh. You are helpful, honest, and concise. "
"You respond in English or Bengali based on the user's language."
)
# ── Hardware detection ────────────────────────────────────────────────────────
HAS_GPU = torch.cuda.is_available()
HAS_BF16 = HAS_GPU and torch.cuda.is_bf16_supported()
USE_FP16 = HAS_GPU and not HAS_BF16
USE_BF16 = HAS_GPU and HAS_BF16
# ── Log helper ────────────────────────────────────────────────────────────────
def log(msg: str, cb: Optional[Callable] = None) -> None:
logger.info(msg)
if cb:
cb(msg)
# ── Dataset download ──────────────────────────────────────────────────────────
def download_dataset(cb: Optional[Callable] = None) -> list[dict]:
"""
Fetch dataset.jsonl from GitHub at runtime.
Always overwrites any existing local copy.
Raises RuntimeError (with a clear message) on any failure.
"""
log("Downloading dataset from GitHub...", cb)
os.makedirs(RUNTIME_DATASET_DIR, exist_ok=True)
try:
urllib.request.urlretrieve(GITHUB_DATASET_URL, RUNTIME_DATASET)
except Exception as exc:
raise RuntimeError(
f"GitHub dataset download failed: {exc}\n"
f"URL checked: {GITHUB_DATASET_URL}"
) from exc
if not os.path.isfile(RUNTIME_DATASET) or os.path.getsize(RUNTIME_DATASET) == 0:
raise RuntimeError(
"Downloaded dataset.jsonl is empty. "
"Check the GitHub URL and repository visibility."
)
records: list[dict] = []
with open(RUNTIME_DATASET, "r", encoding="utf-8") as fh:
for lineno, raw in enumerate(fh, 1):
raw = raw.strip()
if not raw:
continue
try:
records.append(json.loads(raw))
except json.JSONDecodeError as exc:
raise RuntimeError(
f"dataset.jsonl parse error at line {lineno}: {exc}"
) from exc
if not records:
raise RuntimeError(
"dataset.jsonl was downloaded but contains zero valid examples."
)
log("Dataset downloaded successfully.", cb)
log(f"Dataset contains {len(records)} training examples.", cb)
return records
# ── Format helper ─────────────────────────────────────────────────────────────
def format_example(record: dict) -> str:
return (
f"<|im_start|>system\n{SYSTEM_PROMPT}<|im_end|>\n"
f"<|im_start|>user\n{record.get('instruction', '')}<|im_end|>\n"
f"<|im_start|>assistant\n{record.get('output', '')}<|im_end|>"
)
# ── HF Hub helpers ────────────────────────────────────────────────────────────
def adapter_exists_on_hub(repo_id: str, token: str) -> bool:
try:
files = list(list_repo_files(repo_id, repo_type="model", token=token))
return "adapter_config.json" in files
except RepositoryNotFoundError:
return False
except Exception as exc:
logger.warning("Could not check Hub for existing adapter: %s", exc)
return False
def download_adapter_from_hub(repo_id: str, token: str, local_dir: str) -> bool:
"""Download all known adapter files; return True if adapter_config.json landed."""
filenames = [
"adapter_config.json",
"adapter_model.bin",
"adapter_model.safetensors",
"tokenizer_config.json",
"tokenizer.json",
"tokenizer.model",
"special_tokens_map.json",
"training_meta.json",
]
os.makedirs(local_dir, exist_ok=True)
got_config = False
for name in filenames:
try:
hf_hub_download(
repo_id=repo_id, filename=name,
repo_type="model", token=token,
local_dir=local_dir,
)
logger.info(" ↓ %s", name)
if name == "adapter_config.json":
got_config = True
except EntryNotFoundError:
pass
except Exception as exc:
logger.warning(" Could not download %s: %s", name, exc)
return got_config
# ── Model loading ─────────────────────────────────────────────────────────────
def load_base_model_and_tokenizer(cb: Optional[Callable] = None):
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
log(f"Loading tokenizer: {BASE_MODEL_ID}", cb)
tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL_ID, trust_remote_code=True)
tokenizer.pad_token = tokenizer.pad_token or tokenizer.eos_token
tokenizer.padding_side = "right"
bnb_config: Optional[BitsAndBytesConfig] = None
device_map = "auto" if HAS_GPU else "cpu"
if HAS_GPU:
try:
import bitsandbytes # noqa: F401
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16 if HAS_BF16 else torch.float16,
)
log("QLoRA 4-bit quantization enabled (GPU)", cb)
except ImportError:
log("bitsandbytes not installed β€” loading in full precision", cb)
else:
log("CUDA not available β€” running in CPU mode (slower)", cb)
log(f"Loading model: {BASE_MODEL_ID}", cb)
model = AutoModelForCausalLM.from_pretrained(
BASE_MODEL_ID,
quantization_config=bnb_config,
device_map=device_map,
trust_remote_code=True,
dtype=torch.bfloat16 if HAS_BF16 else torch.float16 if HAS_GPU else torch.float32,
low_cpu_mem_usage=True,
)
return model, tokenizer
def _prep_kbit(model):
from peft import prepare_model_for_kbit_training
try:
return prepare_model_for_kbit_training(model)
except Exception:
return model
def apply_lora_fresh(model, cb: Optional[Callable] = None):
from peft import LoraConfig, get_peft_model, TaskType
model = _prep_kbit(model)
config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
r=LORA_RANK,
lora_alpha=LORA_ALPHA,
lora_dropout=LORA_DROPOUT,
target_modules=LORA_TARGET_MODULES,
bias="none",
inference_mode=False,
)
model = get_peft_model(model, config)
model.print_trainable_parameters()
return model
def adapter_base_model(adapter_dir: str) -> Optional[str]:
"""Read base_model_name_or_path from the adapter's config, if available."""
cfg_path = os.path.join(adapter_dir, "adapter_config.json")
try:
with open(cfg_path) as fh:
return json.load(fh).get("base_model_name_or_path")
except Exception:
return None
def load_existing_lora(model, adapter_dir: str, cb: Optional[Callable] = None):
from peft import PeftModel
model = _prep_kbit(model)
log(f"Loading existing LoRA adapter from {adapter_dir}", cb)
model = PeftModel.from_pretrained(model, adapter_dir, is_trainable=True)
model.print_trainable_parameters()
return model
# ── Training ──────────────────────────────────────────────────────────────────
def train(cb: Optional[Callable] = None) -> str:
from transformers import TrainingArguments, Trainer, DataCollatorForLanguageModeling
from datasets import Dataset as HFDataset
log("=" * 55, cb)
log(" MamunAI LoRA Fine-Tuning β€” Owner: Al Mamun Khan", cb)
log("=" * 55, cb)
# Auth
hf_token = os.environ.get("HF_TOKEN", "").strip()
if not hf_token:
raise EnvironmentError(
"HF_TOKEN is not set. Add it as a Space secret or environment variable."
)
try:
login(token=hf_token)
except Exception as exc:
raise RuntimeError(f"Hugging Face authentication failed: {exc}") from exc
create_repo(repo_id=HF_REPO, repo_type="model", exist_ok=True)
# Dataset
raw_data = download_dataset(cb)
# Adapter check
log(f"Checking Hugging Face repository: {HF_REPO}", cb)
resume = adapter_exists_on_hub(HF_REPO, token=hf_token)
if resume:
log("Existing adapter found. Resuming training.", cb)
if os.path.exists(OUTPUT_DIR):
shutil.rmtree(OUTPUT_DIR)
if not download_adapter_from_hub(HF_REPO, token=hf_token, local_dir=OUTPUT_DIR):
log("Adapter download incomplete β€” falling back to fresh start.", cb)
resume = False
# Guard: if the downloaded adapter was trained on a different base model,
# loading it would cause a size-mismatch crash. Detect this early and
# start fresh with a clear message instead.
if resume:
saved_base = adapter_base_model(OUTPUT_DIR)
if saved_base and saved_base != BASE_MODEL_ID:
log(
f"WARNING: Existing adapter was trained on '{saved_base}', "
f"but current base model is '{BASE_MODEL_ID}'. "
"Adapter is incompatible β€” discarding and starting fresh.",
cb,
)
shutil.rmtree(OUTPUT_DIR, ignore_errors=True)
resume = False
if not resume:
log("No adapter found. Starting fresh.", cb)
# Model
model, tokenizer = load_base_model_and_tokenizer(cb)
if resume and os.path.isfile(os.path.join(OUTPUT_DIR, "adapter_config.json")):
model = load_existing_lora(model, OUTPUT_DIR, cb)
else:
model = apply_lora_fresh(model, cb)
# Disable KV cache before gradient checkpointing
model.config.use_cache = False
if hasattr(model, "gradient_checkpointing_enable"):
model.gradient_checkpointing_enable()
log("Gradient checkpointing enabled.", cb)
# Tokenise
formatted = [format_example(r) for r in raw_data]
def tokenize_fn(batch):
out = tokenizer(
batch["text"],
truncation=True,
max_length=MAX_SEQ_LENGTH,
padding="max_length",
return_tensors=None,
)
out["labels"] = out["input_ids"].copy()
return out
hf_ds = HFDataset.from_dict({"text": formatted})
tokenized = hf_ds.map(tokenize_fn, batched=True, remove_columns=["text"])
log(f"Tokenized {len(tokenized)} examples.", cb)
# Training args
os.makedirs(OUTPUT_DIR, exist_ok=True)
args = TrainingArguments(
output_dir=OUTPUT_DIR,
num_train_epochs=NUM_EPOCHS,
per_device_train_batch_size=BATCH_SIZE,
gradient_accumulation_steps=GRAD_ACCUM,
warmup_steps=WARMUP_STEPS,
learning_rate=LEARNING_RATE,
fp16=USE_FP16,
bf16=USE_BF16,
logging_steps=10,
save_strategy="steps",
save_steps=SAVE_STEPS,
save_total_limit=1,
optim="adamw_torch",
report_to="none",
dataloader_num_workers=0,
remove_unused_columns=False,
label_names=["labels"],
)
trainer = Trainer(
model=model,
args=args,
train_dataset=tokenized,
data_collator=DataCollatorForLanguageModeling(
tokenizer=tokenizer, mlm=False, pad_to_multiple_of=8
),
)
log("Starting training...", cb)
trainer.train()
# Save clean adapter (remove any checkpoint subdirs)
log(f"Saving updated LoRA adapter to {OUTPUT_DIR}/", cb)
for item in Path(OUTPUT_DIR).iterdir():
if item.is_dir() and item.name.startswith("checkpoint-"):
shutil.rmtree(item)
model.save_pretrained(OUTPUT_DIR)
tokenizer.save_pretrained(OUTPUT_DIR)
# Cumulative metadata
meta_path = os.path.join(OUTPUT_DIR, "training_meta.json")
prev = {}
if os.path.isfile(meta_path):
with open(meta_path) as fh:
prev = json.load(fh)
runs = prev.get("total_runs", 0) + 1
total = prev.get("total_samples_trained", 0) + len(raw_data)
with open(meta_path, "w") as fh:
json.dump({
"base_model": BASE_MODEL_ID,
"hf_repo": HF_REPO,
"lora_rank": LORA_RANK,
"lora_alpha": LORA_ALPHA,
"lora_dropout": LORA_DROPOUT,
"max_seq_length": MAX_SEQ_LENGTH,
"num_epochs_per_run": NUM_EPOCHS,
"total_runs": runs,
"total_samples_trained": total,
"last_run_samples": len(raw_data),
"target_modules": LORA_TARGET_MODULES,
"dataset_source": GITHUB_DATASET_URL,
}, fh, indent=2)
log(f"Run #{runs} | samples this run: {len(raw_data)} | total: {total}", cb)
# Upload
log("Uploading updated adapter to Hugging Face.", cb)
try:
model.push_to_hub(HF_REPO, commit_message=f"Run {runs} β€” {len(raw_data)} samples")
tokenizer.push_to_hub(HF_REPO, commit_message=f"Run {runs} β€” tokenizer")
HfApi().upload_file(
path_or_fileobj=meta_path,
path_in_repo="training_meta.json",
repo_id=HF_REPO, repo_type="model", token=hf_token,
commit_message=f"Run {runs} β€” metadata",
)
log(f"Adapter uploaded β†’ https://huggingface.co/{HF_REPO}", cb)
except Exception as exc:
log(f"Upload failed: {exc} (adapter saved locally at {OUTPUT_DIR}/)", cb)
log("Training completed successfully.", cb)
return (
f"Training completed successfully. "
f"Run #{runs} | {len(raw_data)} examples | Total samples trained: {total}"
)
# ── Merge ─────────────────────────────────────────────────────────────────────
def merge(
adapter_dir: str = OUTPUT_DIR,
merged_dir: str = MERGED_DIR,
upload_repo: Optional[str] = None,
cb: Optional[Callable] = None,
) -> str:
"""
Merge the LoRA adapter into the base model.
Never called automatically β€” only when explicitly triggered.
Optionally uploads the merged model to a separate HF repo.
"""
from transformers import AutoTokenizer, AutoModelForCausalLM
from peft import PeftModel
log("=" * 55, cb)
log(" Merging LoRA adapter into base model...", cb)
log("=" * 55, cb)
cfg = os.path.join(adapter_dir, "adapter_config.json")
if not os.path.isfile(cfg):
log("Local adapter missing. Downloading from Hugging Face...", cb)
hf_token = os.environ.get("HF_TOKEN", "").strip()
download_adapter_from_hub(
HF_REPO,
token=hf_token,
local_dir=adapter_dir
)
if not os.path.isfile(cfg):
raise FileNotFoundError(
f"Adapter not found locally or on HF repo: {HF_REPO}"
)
tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL_ID)
base = AutoModelForCausalLM.from_pretrained(
BASE_MODEL_ID,
torch_dtype=torch.float32,
low_cpu_mem_usage=True,
device_map="cpu"
)
log(f"Loading LoRA adapter from {adapter_dir}", cb)
merged = PeftModel.from_pretrained(base, adapter_dir).merge_and_unload()
os.makedirs(merged_dir, exist_ok=True)
log(f"Saving merged model to {merged_dir}/", cb)
merged.save_pretrained(merged_dir)
tokenizer.save_pretrained(merged_dir)
result = f"Merge completed successfully. Saved to {merged_dir}/"
if upload_repo:
hf_token = os.environ.get("HF_TOKEN", "").strip()
if not hf_token:
log("HF_TOKEN not set β€” skipping upload.", cb)
else:
try:
login(token=hf_token)
create_repo(repo_id=upload_repo, repo_type="model", exist_ok=True)
log(f"Uploading merged model to {upload_repo}...", cb)
merged.push_to_hub(upload_repo, commit_message="MamunAI merged model")
tokenizer.push_to_hub(upload_repo, commit_message="MamunAI merged tokenizer")
log(f"Merged model uploaded β†’ https://huggingface.co/{upload_repo}", cb)
result += f" | Uploaded to {upload_repo}"
except Exception as exc:
log(f"Upload failed: {exc}", cb)
log("Merge completed successfully.", cb)
return result
# ── CLI ───────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
import argparse
p = argparse.ArgumentParser(description="MamunAI LoRA Fine-Tuning")
p.add_argument("--merge", action="store_true",
help="Merge saved adapter into base model instead of training")
p.add_argument("--adapter-dir", default=OUTPUT_DIR)
p.add_argument("--merged-dir", default=MERGED_DIR)
p.add_argument("--upload-merged-to", default=None,
help="HF repo for the merged model upload (optional)")
a = p.parse_args()
if a.merge:
merge(adapter_dir=a.adapter_dir, merged_dir=a.merged_dir, upload_repo=a.upload_merged_to)
else:
train()