""" Hayat Elixir AI V5 - Meditron3-70B QLoRA Fine-Tuning Script ERROR-PROOF v2 — addresses all 17 known HF Space errors February 2026 NEW in v2: - ERR-17 FIX: Shard-by-shard disk management. Downloads model in streaming mode, deletes cached shards after quantization to keep disk under 10GB at all times. Prevents the infinite restart loop caused by 135GB model exceeding ephemeral disk. CORE RULES APPLIED: - ERR-03/15: Single A100-80GB, gradient_checkpointing, batch_size=1 - ERR-06: total_memory (not total_mem) - ERR-09: trl==0.9.6 API (params in SFTTrainer, not SFTConfig) - ERR-11/12: Single GPU only — NO DDP, NO device_map="auto" for multi-GPU - ERR-16: Flask health server on port 7860 - ERR-17: Disk-safe model loading with shard cleanup """ import os import json import time import gc import glob import shutil import torch import threading from flask import Flask, jsonify from datetime import datetime from huggingface_hub import HfApi, login, snapshot_download # ========== CONFIGURATION ========== MODEL_ID = "OpenMeditron/Meditron3-70B" DATASET_PATH = "/app/hayat_writer_v5_MASTER.jsonl" OUTPUT_DIR = "/app/output" ADAPTER_REPO = "mostafa922/hayat-meditron3-70b-clinical-v5" MODEL_CACHE = "/app/hf_cache" # Training hyperparameters LORA_R = 16 LORA_ALPHA = 32 LORA_DROPOUT = 0.05 NUM_EPOCHS = 3 BATCH_SIZE = 1 GRADIENT_ACCUMULATION = 8 LEARNING_RATE = 2e-4 MAX_SEQ_LENGTH = 1024 WARMUP_RATIO = 0.03 HF_TOKEN = os.environ.get("HF_TOKEN", "") # ========== HEALTH SERVER ========== app = Flask(__name__) training_status = {"stage": "initializing", "progress": 0, "message": "Starting up..."} @app.route("/") def health(): return jsonify(training_status) def start_health_server(): app.run(host="0.0.0.0", port=7860, debug=False, use_reloader=False) health_thread = threading.Thread(target=start_health_server, daemon=True) health_thread.start() print(f"[{datetime.now()}] Health server started on port 7860") def get_disk_usage(): """Get disk usage info for monitoring.""" total, used, free = shutil.disk_usage("/app") return { "total_gb": round(total / (1024**3), 1), "used_gb": round(used / (1024**3), 1), "free_gb": round(free / (1024**3), 1), } def clean_model_cache(): """ERR-17 FIX: Delete all cached model shards to free disk space.""" cache_dirs = [ os.path.join(MODEL_CACHE, "hub"), os.path.join(MODEL_CACHE, "models--OpenMeditron--Meditron3-70B"), "/app/hf_cache/hub", ] freed = 0 for cache_dir in cache_dirs: if os.path.exists(cache_dir): for root, dirs, files in os.walk(cache_dir): for f in files: if f.endswith(('.safetensors', '.bin', '.pt')): fpath = os.path.join(root, f) size = os.path.getsize(fpath) os.remove(fpath) freed += size # Also clean any blob files (HF hub stores shards as blobs) blob_pattern = os.path.join(MODEL_CACHE, "hub", "models--*", "blobs", "*") for blob in glob.glob(blob_pattern): try: size = os.path.getsize(blob) os.remove(blob) freed += size except: pass gc.collect() return freed / (1024**3) def check_gpu(): """ERR-06: Uses total_memory not total_mem.""" if not torch.cuda.is_available(): raise RuntimeError("No CUDA GPU available!") gpu_count = torch.cuda.device_count() print(f"\n{'='*60}") print(f"GPU REPORT") print(f"{'='*60}") for i in range(gpu_count): props = torch.cuda.get_device_properties(i) vram_gb = props.total_memory / (1024**3) print(f" GPU {i}: {props.name} | {vram_gb:.1f} GB VRAM") if gpu_count > 1: print(f"\n WARNING: {gpu_count} GPUs detected — using GPU 0 ONLY (QLoRA + BnB = single GPU)") primary_vram = torch.cuda.get_device_properties(0).total_memory / (1024**3) if primary_vram < 70: raise RuntimeError(f"GPU 0 has only {primary_vram:.1f}GB. Need >=80GB for 70B QLoRA.") print(f"\n GPU 0 has {primary_vram:.1f}GB — sufficient for 70B QLoRA") print(f"{'='*60}\n") return primary_vram # ========== ERR-17 FIX: DISK-SAFE MODEL LOADING ========== def load_model_disk_safe(): """ Load Meditron3-70B with aggressive disk management. Strategy: Use low_cpu_mem_usage=True and let transformers handle shard-by-shard loading. After model is fully loaded and quantized in GPU memory, immediately delete ALL cached files from disk. This prevents the 135GB cache from filling the ephemeral disk. The key insight: once the model weights are in GPU memory (quantized to 4-bit), the disk cache is no longer needed. We only need disk for the adapter output. """ from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig disk = get_disk_usage() print(f"[{datetime.now()}] Disk before download: {disk['used_gb']}GB used / {disk['free_gb']}GB free") # Configure 4-bit quantization bnb_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True, ) print(f"[{datetime.now()}] Downloading tokenizer (small, fast)...") tokenizer = AutoTokenizer.from_pretrained( MODEL_ID, trust_remote_code=True, token=HF_TOKEN, cache_dir=MODEL_CACHE, ) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token tokenizer.pad_token_id = tokenizer.eos_token_id print(f"[{datetime.now()}] Downloading and quantizing model shard-by-shard...") print(f" NOTE: This downloads ~135GB but loads directly into GPU as 4-bit (~35GB)") print(f" Disk will spike during download, then we clean up immediately after.") # Load model — transformers downloads shards sequentially and can handle # low disk if we set low_cpu_mem_usage=True (loads shard → GPU → next shard) model = AutoModelForCausalLM.from_pretrained( MODEL_ID, quantization_config=bnb_config, device_map={"": 0}, # ERR-11/12: Force single GPU torch_dtype=torch.bfloat16, trust_remote_code=True, token=HF_TOKEN, low_cpu_mem_usage=True, # Critical: minimize RAM/disk usage during load cache_dir=MODEL_CACHE, ) # Model is now in GPU memory (quantized). Delete ALL cached files from disk! disk_before = get_disk_usage() print(f"\n[{datetime.now()}] Model loaded into GPU. Disk: {disk_before['used_gb']}GB used") print(f"[{datetime.now()}] ERR-17 FIX: Cleaning model cache from disk...") freed_gb = clean_model_cache() disk_after = get_disk_usage() print(f" Freed {freed_gb:.1f}GB from disk cache") print(f" Disk after cleanup: {disk_after['used_gb']}GB used / {disk_after['free_gb']}GB free") # Log GPU memory allocated = torch.cuda.memory_allocated(0) / (1024**3) reserved = torch.cuda.memory_reserved(0) / (1024**3) vram_gb = torch.cuda.get_device_properties(0).total_memory / (1024**3) print(f" GPU VRAM: {allocated:.1f}GB allocated / {reserved:.1f}GB reserved / {vram_gb:.1f}GB total") return model, tokenizer # ========== MAIN TRAINING ========== def main(): global training_status start_time = time.time() # Step 0: Authenticate training_status = {"stage": "authenticating", "progress": 5, "message": "Logging into HuggingFace..."} print(f"[{datetime.now()}] Authenticating with HuggingFace...") if HF_TOKEN: login(token=HF_TOKEN) print(f" Authenticated with HF token") else: print(f" WARNING: No HF_TOKEN — gated model access may fail") # Step 1: GPU Check training_status = {"stage": "gpu_check", "progress": 10, "message": "Checking GPU..."} vram_gb = check_gpu() # Step 2: Disk-safe model loading (ERR-17 FIX) training_status = {"stage": "loading_model", "progress": 15, "message": "Downloading & quantizing Meditron3-70B (disk-safe mode)..."} model, tokenizer = load_model_disk_safe() training_status = {"stage": "model_loaded", "progress": 40, "message": "Model loaded. Preparing QLoRA..."} # Step 3: QLoRA setup print(f"[{datetime.now()}] Preparing QLoRA adapter (r={LORA_R}, alpha={LORA_ALPHA})...") from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training model = prepare_model_for_kbit_training(model) model.gradient_checkpointing_enable() lora_config = LoraConfig( r=LORA_R, lora_alpha=LORA_ALPHA, lora_dropout=LORA_DROPOUT, bias="none", task_type="CAUSAL_LM", target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"], ) model = get_peft_model(model, lora_config) trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad) total_params = sum(p.numel() for p in model.parameters()) print(f" Trainable: {trainable_params:,} / {total_params:,} ({100*trainable_params/total_params:.2f}%)") # Step 4: Load dataset training_status = {"stage": "loading_data", "progress": 45, "message": "Loading 840-example dataset..."} print(f"[{datetime.now()}] Loading dataset from {DATASET_PATH}...") from datasets import Dataset examples = [] with open(DATASET_PATH) as f: for line in f: d = json.loads(line) text = tokenizer.apply_chat_template(d["messages"], tokenize=False, add_generation_prompt=False) examples.append({"text": text}) dataset = Dataset.from_list(examples) print(f" Loaded {len(dataset)} examples") # Final disk check before training disk = get_disk_usage() print(f" Disk before training: {disk['used_gb']}GB used / {disk['free_gb']}GB free") # Step 5: Training training_status = {"stage": "training", "progress": 50, "message": "Training started (3 epochs, 840 examples)..."} print(f"\n[{datetime.now()}] Starting training...") print(f" epochs={NUM_EPOCHS}, batch={BATCH_SIZE}, grad_accum={GRADIENT_ACCUMULATION}") print(f" effective_batch={BATCH_SIZE * GRADIENT_ACCUMULATION}, lr={LEARNING_RATE}") print(f" max_seq_length={MAX_SEQ_LENGTH}") from transformers import TrainingArguments from trl import SFTTrainer # ERR-09: trl==0.9.6 API training_args = TrainingArguments( output_dir=OUTPUT_DIR, num_train_epochs=NUM_EPOCHS, per_device_train_batch_size=BATCH_SIZE, gradient_accumulation_steps=GRADIENT_ACCUMULATION, learning_rate=LEARNING_RATE, warmup_ratio=WARMUP_RATIO, optim="paged_adamw_8bit", # ERR-15: 8-bit optimizer fp16=False, bf16=True, logging_steps=5, save_strategy="epoch", save_total_limit=2, gradient_checkpointing=True, # ERR-15: saves ~40% VRAM gradient_checkpointing_kwargs={"use_reentrant": False}, report_to="none", max_grad_norm=0.3, lr_scheduler_type="cosine", seed=42, ) # ERR-09: trl==0.9.6 — params in SFTTrainer constructor trainer = SFTTrainer( model=model, args=training_args, train_dataset=dataset, tokenizer=tokenizer, max_seq_length=MAX_SEQ_LENGTH, dataset_text_field="text", packing=False, ) # Progress callback class StatusCallback: def on_log(self, args, state, control, logs=None, **kwargs): global training_status if state.global_step > 0 and logs: progress = min(95, 50 + int(45 * state.global_step / state.max_steps)) loss = logs.get("loss", "N/A") training_status = { "stage": "training", "progress": progress, "message": f"Step {state.global_step}/{state.max_steps} | Loss: {loss}", } print(f" Step {state.global_step}/{state.max_steps} | Loss: {loss}") trainer.add_callback(StatusCallback()) # TRAIN train_result = trainer.train() train_time = time.time() - start_time print(f"\n[{datetime.now()}] Training complete!") print(f" Time: {train_time/60:.1f} minutes | Loss: {train_result.training_loss:.4f}") # Step 6: Save adapter training_status = {"stage": "saving", "progress": 96, "message": "Saving adapter files..."} print(f"[{datetime.now()}] Saving adapter...") # Clean up checkpoint dirs to save disk before final save for d in glob.glob(os.path.join(OUTPUT_DIR, "checkpoint-*")): shutil.rmtree(d, ignore_errors=True) trainer.save_model(OUTPUT_DIR) tokenizer.save_pretrained(OUTPUT_DIR) summary = { "model_id": MODEL_ID, "adapter_repo": ADAPTER_REPO, "dataset_size": len(dataset), "training_time_minutes": round(train_time / 60, 1), "final_loss": round(train_result.training_loss, 4), "epochs": NUM_EPOCHS, "lora_r": LORA_R, "lora_alpha": LORA_ALPHA, "learning_rate": LEARNING_RATE, "max_seq_length": MAX_SEQ_LENGTH, "batch_size": BATCH_SIZE, "gradient_accumulation": GRADIENT_ACCUMULATION, "effective_batch_size": BATCH_SIZE * GRADIENT_ACCUMULATION, "optimizer": "paged_adamw_8bit", "gpu": torch.cuda.get_device_name(0), "vram_gb": round(vram_gb, 1), "version": "V5.1-840ex-compliance-fix", "timestamp": datetime.now().isoformat(), "compliance_fix": "Added 20 compliance trap examples (Q24/Q37 Arabic greeting fix)", "disk_management": "ERR-17 fix: shard cleanup after GPU load", } with open(os.path.join(OUTPUT_DIR, "training_summary.json"), "w") as f: json.dump(summary, f, indent=2) # Step 7: Upload to HuggingFace Hub training_status = {"stage": "uploading", "progress": 97, "message": "Uploading adapter to HuggingFace Hub..."} print(f"[{datetime.now()}] Uploading adapter to {ADAPTER_REPO}...") api = HfApi() try: api.create_repo(repo_id=ADAPTER_REPO, exist_ok=True, token=HF_TOKEN) api.upload_folder( folder_path=OUTPUT_DIR, repo_id=ADAPTER_REPO, token=HF_TOKEN, commit_message=f"V5.1 retrain: 840ex + compliance fix + ERR-17 disk-safe", ) print(f" Uploaded to https://huggingface.co/{ADAPTER_REPO}") except Exception as e: print(f" Upload error: {e}") print(f" Adapter saved locally at {OUTPUT_DIR}") # List output files print(f"\n{'='*60}") print(f"OUTPUT FILES") print(f"{'='*60}") for f in sorted(os.listdir(OUTPUT_DIR)): fpath = os.path.join(OUTPUT_DIR, f) if os.path.isfile(fpath): size_mb = os.path.getsize(fpath) / (1024**1024) print(f" {f}: {size_mb:.1f} MB") disk = get_disk_usage() print(f"\nFinal disk: {disk['used_gb']}GB used / {disk['free_gb']}GB free") # Done training_status = { "stage": "completed", "progress": 100, "message": f"DONE! Loss: {train_result.training_loss:.4f} | {train_time/60:.1f}min | 840 examples", "summary": summary, } print(f"\n{'='*60}") print(f"TRAINING COMPLETE") print(f" Dataset: {len(dataset)} examples (820 + 20 compliance fixes)") print(f" Final loss: {train_result.training_loss:.4f}") print(f" Time: {train_time/60:.1f} minutes") print(f" Adapter: https://huggingface.co/{ADAPTER_REPO}") print(f"{'='*60}") # Keep alive 10 min for log reading print(f"\n[{datetime.now()}] Keeping alive 10 min for log access...") print(f" >>> PAUSE THIS SPACE after downloading adapter! <<<") time.sleep(600) print(f"[{datetime.now()}] Auto-exit.") if __name__ == "__main__": main()