Spaces:
Running on Zero
Running on Zero
| """ | |
| 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() | |