"""LLM Training Pipeline -- Deslop + SFT LoRA. Single-file Gradio app. Model-agnostic: works with any HF causal LM. Default: unsloth/gemma-4-E2B-it-unsloth-bnb-4bit. Steps (each optional, checkbox): 1. Deslop -- remove AI slop via FTPO training (auto-antislop-style) 2. SFT LoRA -- fine-tune on user's chat dataset (TRL SFTTrainer) """ import gc import json import math import os import queue import re import shutil import threading import time import warnings import zipfile from pathlib import Path import gradio as gr import torch from theme import theme as hz_theme, THEME_CSS, THEME_HEAD, THEME_JS warnings.filterwarnings("ignore", message=".*_check_is_size.*") warnings.filterwarnings("ignore", message=".*quantization_config.*from the model will be used.*") warnings.filterwarnings("ignore", message=".*torch_dtype.*deprecated.*") # ── Shared helpers ────────────────────────────────────────── _SHAREGPT_ROLE_MAP = {"human": "user", "gpt": "assistant", "observation": "user", "function_call": "assistant"} def _is_4bit(model_id): if any(q in model_id.lower() for q in ["bnb-4bit", "4bit", "bnb_4bit"]): return True try: from transformers import AutoConfig cfg = AutoConfig.from_pretrained(model_id) if hasattr(cfg, "quantization_config") and cfg.quantization_config: return cfg.quantization_config.get("quant_method") in ("bitsandbytes", "gptq") except Exception: pass return False # ── Globals ────────────────────────────────────────────────── WORK_DIR = "/tmp/pipeline" LORA_DIR = os.path.join(WORK_DIR, "loras") os.makedirs(WORK_DIR, exist_ok=True) os.makedirs(LORA_DIR, exist_ok=True) DEFAULT_MODEL = "unsloth/gemma-4-E2B-it-unsloth-bnb-4bit" _max_h = int(os.environ.get("MAX_HOUR_TRAINING_TIME", 0)) MAX_TRAINING_TIME = _max_h * 3600 if _max_h else None _START = time.time() _LOG_Q = queue.Queue() _LOG_DEDUP = "" _PHASE = "idle" _CANCEL = False def _elapsed(): s = int(time.time() - _START) return f"+{s//3600:02d}h{(s%3600)//60:02d}" def log(msg): global _LOG_DEDUP line = f"[{_elapsed()}] {msg}" if msg == _LOG_DEDUP: return _LOG_DEDUP = msg print(line, flush=True) _LOG_Q.put(line) # ── RAM monitor ────────────────────────────────────────────── def _ram_loop(): _last_cg = 0.0 while True: try: climit, cused = None, None for p in ("/sys/fs/cgroup/memory.max", "/sys/fs/cgroup/memory/memory.limit_in_bytes"): try: with open(p) as f: v = f.read().strip() if v != "max": climit = int(v) / 1e9 break except FileNotFoundError: pass for p in ("/sys/fs/cgroup/memory.current", "/sys/fs/cgroup/memory/memory.usage_in_bytes"): try: with open(p) as f: cused = int(f.read().strip()) / 1e9 break except FileNotFoundError: pass if climit and cused and abs(cused - _last_cg) >= 3.0: arrow = "↑" if cused > _last_cg else "↓" if _last_cg > 0 else "" log(f"💾 RAM{arrow} {cused:.1f}/{climit:.1f} GB ({cused/climit*100:.0f}%)") _last_cg = cused except Exception as e: print(f"[RAM] error: {e}", flush=True) time.sleep(30) threading.Thread(target=_ram_loop, daemon=True).start() # ── Helpers ────────────────────────────────────────────────── def _apply_chat_template(tokenizer, messages, thinking=False, **kwargs): kwargs.setdefault("tokenize", False) kwargs.setdefault("add_generation_prompt", True) try: return tokenizer.apply_chat_template(messages, enable_thinking=thinking, **kwargs) except TypeError: return tokenizer.apply_chat_template(messages, **kwargs) def _auto_split_txt(text, max_seq_tokens=1024): """Split .txt into training samples. Prefers smaller chunks over bigger ones.""" text = text.replace("\r\n", "\n").replace("\r", "\n").strip() if not text: return [] safe_chars = int(max_seq_tokens * 4 * 0.90) # Step 1: split on blank lines (always a boundary) blocks = re.split(r"\n\s*\n+", text) blocks = [b.strip() for b in blocks if b.strip()] # Step 2: within each block, decide per-line SPLIT or KEEP samples = [] for block in blocks: lines = block.split("\n") if len(lines) == 1: samples.append(lines[0].strip()) continue # Detect strong continuation: commas/semicolons and dialogue markers only continuation_count = 0 for line in lines[:-1]: ls = line.strip() if ls and ls[-1] in ",;:": continuation_count += 1 elif re.match(r'^[A-Z][a-z]+:', ls): continuation_count += 1 is_prose = continuation_count >= len(lines) * 0.6 if is_prose: # Prose/dialogue: continuations detected, keep block together samples.append(block) else: # List or ambiguous: prefer split per line for line in lines: if line.strip(): samples.append(line.strip()) # Step 3: split overlong samples by sentence, then hard cap final = [] for s in samples: if len(s) <= safe_chars: final.append(s) else: sents = re.split(r'(?<=[.!?])\s+', s) chunk = "" for sent in sents: if len(chunk) + len(sent) + 1 > safe_chars and chunk: final.append(chunk.strip()) chunk = "" chunk += (" " if chunk else "") + sent if chunk.strip(): final.append(chunk.strip()) # Hard cap anything still too long capped = [] for f in final: if f.strip(): capped.append(f[:safe_chars] if len(f) > safe_chars else f) # Similarity dedup via SimHash (O(n) per sample, fast for large files) if len(capped) > 1: def _simhash(text, bits=64): v = [0] * bits for word in text.lower().split(): h = hash(word) for i in range(bits): v[i] += 1 if h & (1 << i) else -1 return sum((1 << i) for i in range(bits) if v[i] > 0) def _hamming(a, b, bits=64): x = a ^ b return bin(x).count('1') / bits hashes = [_simhash(s) for s in capped] kept = [0] for i in range(1, len(capped)): is_dup = any(_hamming(hashes[i], hashes[j]) < 0.15 for j in kept) if not is_dup: kept.append(i) if len(kept) < len(capped): log(f" Similarity dedup: {len(capped)} → {len(kept)} samples ({len(capped) - len(kept)} removed)") return [capped[i] for i in kept] return capped def _estimate_ram(model_id, lora_rank=16, max_seq=2048): """Estimate peak training RAM from HF API metadata. Returns (estimate_gb, detail_str). Uses safetensors param-count-per-dtype (NOT file sizes) to compute model RSS, then adds architecture-aware training overhead from config.json. Peak = (model_RSS + LoRA_FP32 + optimizer + gradients + activations + baseline) * 1.15 The 1.15x covers PyTorch allocator fragmentation, autograd graph metadata, temporary GEMM buffers, and other runtime overhead. Calibrated against: unsloth/gemma-4-E4B-it-unsloth-bnb-4bit (bnb 4-bit, 8B params) Actual model RSS: 6.5 GB, actual peak training RSS: 13.2 GB Config: rank=16, max_seq=2048, batch=1, grad_accum=5, gc=True, Adafactor Estimate: 13.2 GB (0.1% error) """ try: from huggingface_hub import HfApi, hf_hub_download api = HfApi() info = api.model_info(model_id, files_metadata=True) # ── 1. Model in-memory size from safetensors param counts ── st_meta = getattr(info, "safetensors", None) is_bnb4 = _is_4bit(model_id) model_gb = 0.0 if st_meta and hasattr(st_meta, "parameters"): params_by_dtype = st_meta.parameters or {} _DTYPE_BYTES = {"F64": 8, "F32": 4, "F16": 2, "BF16": 2, "I64": 8, "I32": 4, "I16": 2, "I8": 1, "U8": 1, "F8_E5M2": 1, "F8_E4M3": 1, "BOOL": 1} if is_bnb4: # BNB 4-bit pre-quantized: BF16/F16 param counts are quantized # weight containers. In memory each occupies ~0.55 bytes/param # (NF4 + double-quant scale overhead). U8 params are quant-state # tensors at 1 byte/param. F32 params (biases/norms) at 4 bytes. for dtype_key, count in params_by_dtype.items(): dk = dtype_key.upper().replace("FLOAT", "F").replace("INT", "I") if dk in ("BF16", "F16"): model_gb += count * 0.55 / 1e9 elif dk == "U8": model_gb += count * 1.0 / 1e9 else: model_gb += count * _DTYPE_BYTES.get(dk, 4) / 1e9 else: for dtype_key, count in params_by_dtype.items(): dk = dtype_key.upper().replace("FLOAT", "F").replace("INT", "I") model_gb += count * _DTYPE_BYTES.get(dk, 4) / 1e9 if model_gb == 0: # Fallback: on-disk file sizes (less accurate for quantized models) disk_gb = sum(s.size for s in info.siblings if s.rfilename.endswith((".safetensors", ".bin")) and s.size) / 1e9 model_gb = disk_gb * (0.6 if is_bnb4 else 1.0) if model_gb == 0: return 0, "unknown" # ── 2. Architecture from config.json ── hidden = 2560 n_layers = 32 intermediate = 8192 vocab_size = 32000 try: cfg_raw = json.loads(Path(hf_hub_download(model_id, "config.json")).read_text()) tcfg = cfg_raw.get("text_config", cfg_raw) hidden = tcfg.get("hidden_size", hidden) n_layers = tcfg.get("num_hidden_layers", n_layers) intermediate = tcfg.get("intermediate_size", intermediate) vocab_size = tcfg.get("vocab_size", vocab_size) except Exception: pass # ── 3. LoRA trainable params in FP32 ── # 7 target modules/layer: q,k,v,o_proj (attn) + gate,up,down_proj (MLP) # Each LoRA pair: A(rank x in) + B(out x rank) params lora_params = (4 * hidden * lora_rank * 2 # attn: 4 modules + 3 * (hidden + intermediate) * lora_rank # MLP: 3 modules ) * n_layers lora_gb = lora_params * 4 / 1e9 # stored in FP32 # Optimizer: Adafactor stores row+col factors (~1x param memory) optim_gb = lora_gb # Gradients for LoRA params (FP32) grad_gb = lora_gb # ── 4. Activations with gradient checkpointing (batch=1) ── # With GC, PyTorch stores hidden states at sqrt(n_layers) checkpoint # boundaries. During backward, it recomputes the full forward pass for # one segment (sqrt(n_layers) layers), creating all intermediate tensors # simultaneously, then backprops through that segment. # # Per-layer forward intermediates (FP32 for backward computation): # 7 hidden-dim tensors: input_hs, QKV_out(~H), attn_out, post_attn_res, # pre_mlp_norm, down_proj_out, post_mlp_res # 3 intermediate-dim tensors: gate_proj, up_proj, gate*up (SwiGLU) # Plus current-layer backward gradients (same size as forward intermediates). segment_size = math.ceil(math.sqrt(n_layers)) per_layer_bytes = max_seq * (7 * hidden + 3 * intermediate) * 4 # segment forward + 1 layer backward + checkpoint storage act_gb = ((segment_size + 1) * per_layer_bytes # fwd + bwd + segment_size * max_seq * hidden * 4 # checkpoints ) / 1e9 # Chunked logits (loss_type="chunked_nll", chunk_size=128) logits_gb = vocab_size * 128 * 4 / 1e9 # vocab * chunk * FP32 # Framework baseline: Python runtime, tokenizer, misc buffers baseline_gb = 1.5 # ── 5. Total with fragmentation overhead ── # 15% covers PyTorch allocator fragmentation, autograd graph metadata, # temporary matmul buffers, and other runtime overhead. subtotal = model_gb + lora_gb + optim_gb + grad_gb + act_gb + logits_gb + baseline_gb peak_gb = subtotal * 1.15 detail = (f"~{peak_gb:.0f}GB peak (model={model_gb:.1f} + " f"train={lora_gb + optim_gb + grad_gb:.1f} + " f"act={act_gb:.1f} + logits={logits_gb:.1f} + " f"base={baseline_gb} + 15%overhead)") return peak_gb, detail except Exception: return 0, "unknown" def _parse_dataset(file_obj, max_seq=1024): if isinstance(file_obj, list): all_samples = [] for f in file_obj: all_samples.extend(_parse_dataset(f, max_seq)) return all_samples import pandas as pd path = file_obj.name if hasattr(file_obj, "name") else file_obj ext = Path(path).suffix.lower() if ext == ".txt": size_mb = os.path.getsize(path) / 1e6 if size_mb > 100: raise ValueError(f".txt file is {size_mb:.0f} MB — too large. Split into smaller files or use .jsonl") with open(path, encoding="utf-8", errors="replace") as fh: text = fh.read() samples = _auto_split_txt(text, max_seq_tokens=max_seq) if not samples: raise ValueError("Empty .txt file") return [{"messages": [{"role": "user", "content": ""}, {"role": "assistant", "content": s}]} for s in samples] if ext == ".jsonl": with open(path, encoding="utf-8") as fh: rows = [json.loads(line) for line in fh if line.strip()] df = pd.DataFrame(rows) elif ext == ".json": with open(path, encoding="utf-8") as fh: d = json.load(fh) df = pd.DataFrame(d if isinstance(d, list) else [d]) elif ext == ".csv": df = pd.read_csv(path, encoding="utf-8") elif ext == ".parquet": df = pd.read_parquet(path) else: raise ValueError(f"Unsupported: {ext}") cols = set(df.columns) if "messages" in cols: return [{"messages": json.loads(r["messages"]) if isinstance(r["messages"], str) else r["messages"]} for _, r in df.iterrows()] if "conversations" in cols: results = [] for _, r in df.iterrows(): convs = json.loads(r["conversations"]) if isinstance(r["conversations"], str) else r["conversations"] msgs = [] if r.get("system"): msgs.append({"role": "system", "content": str(r["system"])}) for m in convs: role = _SHAREGPT_ROLE_MAP.get(m.get("from", ""), m.get("from", "user")) msgs.append({"role": role, "content": m.get("value", m.get("content", ""))}) results.append({"messages": msgs}) return results if "instruction" in cols: results = [] for _, r in df.iterrows(): msgs = [] if r.get("system"): msgs.append({"role": "system", "content": str(r["system"])}) hist = r.get("history") if hist: hist = json.loads(hist) if isinstance(hist, str) else hist for pair in hist: if isinstance(pair, (list, tuple)) and len(pair) >= 2: msgs.append({"role": "user", "content": str(pair[0])}) msgs.append({"role": "assistant", "content": str(pair[1])}) user_content = str(r.get("instruction", "") or "") inp = r.get("input") if inp is not None and str(inp) not in ("", "nan"): user_content += "\n" + str(inp) msgs.append({"role": "user", "content": user_content}) out = r.get("output", "") msgs.append({"role": "assistant", "content": str(out) if str(out) != "nan" else ""}) results.append({"messages": msgs}) return results for c in ["text", "content", "completion"]: if c in cols: return [{"messages": [{"role": "user", "content": ""}, {"role": "assistant", "content": str(r[c])}]} for _, r in df.iterrows()] raise ValueError(f"Can't auto-detect format. Columns: {list(df.columns)[:10]}. Expected: messages, conversations, instruction+output, or text") def _load_model(model_id, device_map="cpu", load_in_4bit=False): from transformers import AutoTokenizer, AutoModelForCausalLM t0 = time.time() print(f"[{_elapsed()}] Loading {model_id}...", flush=True) tok = AutoTokenizer.from_pretrained(model_id) if tok.pad_token is None: tok.pad_token = tok.eos_token if not getattr(tok, "chat_template", None): base_id = model_id.replace("-bnb-4bit", "") try: base_tok = AutoTokenizer.from_pretrained(base_id) if getattr(base_tok, "chat_template", None): tok.chat_template = base_tok.chat_template log(f" ⚠️ chat_template missing, borrowed from {base_id}") except Exception: pass kwargs = {"device_map": device_map, "attn_implementation": "sdpa"} if load_in_4bit: from transformers import BitsAndBytesConfig kwargs["quantization_config"] = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.float32, bnb_4bit_use_double_quant=True) else: kwargs["dtype"] = torch.bfloat16 mdl = AutoModelForCausalLM.from_pretrained(model_id, **kwargs) log(f"📥 Loaded {model_id.split('/')[-1]} in {time.time()-t0:.0f}s ({sum(p.numel() for p in mdl.parameters())/1e9:.1f}B{', 4-bit' if load_in_4bit else ''})") return mdl, tok def _list_loras(): loras = ["(none)"] for d in sorted(Path(LORA_DIR).iterdir()) if Path(LORA_DIR).exists() else []: if d.is_dir() and (d / "adapter_config.json").exists(): loras.append(d.name) out = os.path.join(WORK_DIR, "output") for step in ["deslop_lora", "sft_lora"]: p = os.path.join(out, step) if os.path.isdir(p) and os.path.isfile(os.path.join(p, "adapter_config.json")): loras.append(f"output/{step}") pa = os.path.join(p, "lora_adapter") if os.path.isdir(pa) and os.path.isfile(os.path.join(pa, "adapter_config.json")): loras.append(f"output/{step}/lora_adapter") return loras _LORA_TARGETS = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"] def _make_lora_config(rank, model=None): from peft import LoraConfig kwargs = {"r": rank, "lora_alpha": rank * 2, "target_modules": _LORA_TARGETS, "lora_dropout": 0.05, "bias": "none", "task_type": "CAUSAL_LM"} if model is not None and hasattr(model, 'model'): for pattern in ["language_model.layers", "model.layers", "layers"]: try: obj = model for part in ("model." + pattern).split("."): obj = getattr(obj, part) n_layers = len(obj) kwargs["layers_to_transform"] = list(range(n_layers)) kwargs["layers_pattern"] = pattern break except (AttributeError, TypeError): continue return LoraConfig(**kwargs) # ╔════════════════════════════════════════════════════════════╗ # ║ STEP 1: DESLOP (FTPO) ║ # ╚════════════════════════════════════════════════════════════╝ _FALLBACK_SLOP_PHRASES = [ "took a deep breath", "voice barely above a whisper", "couldn't help but feel", "help but feel a sense", "voice barely audible", "casting long shadows", "voice barely a whisper", "couldn't shake the feeling", "couldn't help but wonder", "long shadows across", "heart pounding in my chest", "sun dipped below the horizon", "felt a chill run", "air was thick with the scent", "felt like an eternity", "heart pounding in her chest", "voice steady despite", "felt a shiver run", "said, his voice low", "room fell silent", "ready to face whatever", "trying to make sense", "said, his voice barely", "said, her voice barely", "asked, my voice barely", "deep breath, trying", "felt a strange sense", "something else entirely", "could feel the weight", "words hung in the air", "heart pounding in his chest", "brow furrowed in concentration", "sun began to set", "smile playing on his lips", "voice trembling slightly", "asked, her voice barely", "door creaked open", "eyes never leaving", "days turned into weeks", "voice a low rumble", "growing sense of unease", "took a step back", "heart skipped a beat", "air hung thick", "said, her voice steady", "rain continued to fall", "sun hung low", "shiver run down my spine", "took a step forward", "said, my voice barely", "casting a warm glow", "renewed sense of purpose", "spreading across his face", "taking a deep breath", "horizon, casting long", "hung low in the sky", "whispered, her voice barely", "smile spreading across", "leaned back in his chair", "hung heavy in the air", "eyes wide with fear", "took a step closer", "shake the feeling that something", "face whatever challenges", "one last time", "spread like wildfire", "asked, his voice barely", "felt a sense of peace", "newfound sense of purpose", "door swung open", "grin spreading across", "eyes filled with a mixture", "said, his voice a low", "eyes locked onto", "in conclusion", "to summarize", "I'd be happy to", "delve", "tapestry", "vibrant", "testament to", "it's important to note", "certainly", "absolutely", ] _AUTO_ANTISLOP_PHRASE_LIMIT = 200 def _phrase_from_slop_item(item): if isinstance(item, str): return item.strip() if isinstance(item, (list, tuple)) and item and isinstance(item[0], str): return item[0].strip() return "" def _load_slop_phrase_file(path, limit=None): phrases = [] seen = set() def add_phrase(raw): phrase = raw.strip() key = phrase.lower() if phrase and key not in seen: seen.add(key) phrases.append(phrase) if path.suffix.lower() == ".json": with open(path, encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): iterable = data.keys() elif isinstance(data, list): iterable = data else: iterable = [] for item in iterable: add_phrase(_phrase_from_slop_item(item)) if limit and len(phrases) >= limit: break else: with open(path, encoding="utf-8", errors="replace") as f: for line in f: text = line.strip() if not text or text.startswith("#"): continue add_phrase(text.split("\t", 1)[0].split(",", 1)[0]) if limit and len(phrases) >= limit: break return phrases def _load_auto_antislop_phrases(): base = Path(__file__).resolve().parent candidates = [ base / "auto-antislop-review" / "antislop-vllm" / "banlists" / "slop_phrases.json", base / "auto-antislop-review" / "slop-forensics" / "data" / "slop_list_trigrams.json", base / "auto-antislop-review" / "slop-forensics" / "data" / "slop_list_bigrams.json", base / "auto-antislop-review" / "slop-forensics" / "data" / "slop_list.json", ] for path in candidates: if not path.exists(): continue try: phrases = _load_slop_phrase_file(path, limit=_AUTO_ANTISLOP_PHRASE_LIMIT) except Exception as e: log(f" WARNING: failed to load slop phrases from {path}: {e}") continue if phrases: log(f" Slop phrase source: {path} ({len(phrases)} phrases)") return phrases, str(path) return list(_FALLBACK_SLOP_PHRASES), "fallback seed list" SLOP_PHRASES, SLOP_PHRASES_SOURCE = _load_auto_antislop_phrases() _SLOP_PHRASES_LOWER = [p.lower() for p in SLOP_PHRASES] _HF_DESLOP_DATASET = ("Nitral-AI/Reddit-SFW-Writing_Prompts_ShareGPT", "train") _DESLOP_PROMPT_TEMPLATE = "Writing prompt: {prompt}\n\nWrite 1000 words to this prompt. Your response:\n" _DESLOP_SYSTEM_PROMPT = "You are an uncensored writer." _FTPO_DEFAULT_PROMPTS = 5 _FTPO_PAIR_MAX_NEW_TOKENS = 100 _FALLBACK_DESLOP_PROMPTS = [ "Write a short story about a mysterious stranger arriving in a small town.", "Describe a sunset over the ocean in vivid detail.", "Write a conversation between two old friends meeting after 20 years.", "Write a story about someone discovering a hidden room in their house.", "Write about a detective solving their first case.", ] def _load_deslop_prompts(n=_FTPO_DEFAULT_PROMPTS): try: from datasets import load_dataset ds = load_dataset(_HF_DESLOP_DATASET[0], split=f"{_HF_DESLOP_DATASET[1]}[:{n * 3}]") prompts = [] for row in ds: convos = row.get("conversations") or row.get("messages") or [] for msg in convos: if isinstance(msg, dict) and msg.get("from") in ("human", "user"): text = msg.get("value") or msg.get("content") or "" if len(text) > 20: prompts.append(text.strip()) break if len(prompts) >= n: break log(f" Loaded {len(prompts)} prompts from {_HF_DESLOP_DATASET[0]}") return prompts except Exception as e: log(f" ⚠️ Failed to load deslop prompts: {e}") return _FALLBACK_DESLOP_PROMPTS[:n] def detect_slop(text): text_lower = text.lower() return [p for p, pl in zip(SLOP_PHRASES, _SLOP_PHRASES_LOWER) if pl in text_lower] _FTPO_STOP_WORDS = { "the", "a", "an", "in", "on", "at", "by", "for", "to", "of", "and", "or", "but", "if", "then", "else", "when", "where", "how", "why", "what", "who", "whom", "this", "that", "these", "those", "is", "are", "was", "were", "be", "being", "been", "have", "has", "had", "do", "does", "did", "will", "would", "shall", "should", "can", "could", "may", "might", "must", } def _candidate_token_ids(score, limit=20, min_p=0.01): """Extract top candidate token ids from a score vector (raw logits). Applies min_p filtering: keep tokens whose probability is >= min_p * max_prob. The score input is raw logits, so we convert to log-probs first. """ if score is None or score.numel() == 0: return [] top_n = min(limit, score.shape[-1]) # Convert raw logits to log-probabilities for correct min_p filtering log_probs = torch.nn.functional.log_softmax(score.float(), dim=-1) vals, idx = torch.topk(log_probs, top_n) # min_p threshold: keep tokens with prob >= min_p * max_prob # In log space: log_prob >= log(max_prob) + log(min_p) threshold = vals[0].item() + math.log(min_p) result = [tid for val, tid in zip(vals.tolist(), idx.tolist()) if val >= threshold] return result def _generate_baseline(model, tokenizer, prompts, max_new_tokens=_FTPO_PAIR_MAX_NEW_TOKENS, candidate_pool=20, min_p=0.01): import time as _time max_new_tokens = min(int(max_new_tokens), _FTPO_PAIR_MAX_NEW_TOKENS) device = next(model.parameters()).device results = [] for i, prompt in enumerate(prompts): t0 = _time.monotonic() formatted = _DESLOP_PROMPT_TEMPLATE.format(prompt=prompt) messages = [{"role": "system", "content": _DESLOP_SYSTEM_PROMPT}, {"role": "user", "content": formatted}] text = _apply_chat_template(tokenizer, messages) inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=512) inputs = {k: v.to(device) for k, v in inputs.items()} with torch.no_grad(): out = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False, output_scores=True, return_dict_in_generate=True) gen_ids = out.sequences[0][inputs["input_ids"].shape[1]:] gen_text = tokenizer.decode(gen_ids, skip_special_tokens=True) # out.scores is a tuple of tensors, each shape (batch_size, vocab_size) # Extract candidate tokens for each generated position n_scores = len(out.scores) if out.scores else 0 scores = [] if out.scores: for s in out.scores: # s shape: (batch_size, vocab_size) -- take first batch element score_vec = s[0] if s.dim() > 1 else s candidates = _candidate_token_ids(score_vec, limit=candidate_pool, min_p=min_p) scores.append(candidates) elapsed = _time.monotonic() - t0 n_gen = len(gen_ids) log(f" Generated {i+1}/{len(prompts)}: {n_gen} tokens, {n_scores} scores, {elapsed:.1f}s ({elapsed/max(n_gen,1):.2f}s/tok)") if n_scores != n_gen: log(f" WARNING: score count ({n_scores}) != gen token count ({n_gen})") results.append({"prompt": prompt, "prompt_ids": inputs["input_ids"][0].tolist(), "gen_text": gen_text, "gen_ids": gen_ids.tolist(), "scores": scores}) del out, inputs if _CANCEL or (MAX_TRAINING_TIME and time.time() - _START > MAX_TRAINING_TIME): log(" Generation stopped (cancel/timeout)") break gc.collect() return results def _find_ftpo_rejected_token_pos(gen_text, gen_ids, phrase, tokenizer): idx = gen_text.lower().find(phrase.lower()) if idx < 0: return -1, "phrase_not_found" phrase_end = idx + len(phrase) # Build character offset map in a single pass (O(n) instead of O(n^2)) # Decode each token individually and track cumulative character positions char_pos = 0 token_spans = [] # (start_char, end_char) for each token for ti in range(len(gen_ids)): token_text = tokenizer.decode([gen_ids[ti]], skip_special_tokens=True) token_len = len(token_text) token_spans.append((char_pos, char_pos + token_len)) char_pos += token_len # If individual decode lengths don't sum to gen_text length (due to tokenizer # merging adjacent tokens), fall back to cumulative decode for accuracy if char_pos != len(gen_text): token_spans = [] prev_len = 0 for ti in range(len(gen_ids)): decoded_so_far = tokenizer.decode(gen_ids[:ti + 1], skip_special_tokens=True) cur_len = len(decoded_so_far) token_spans.append((prev_len, cur_len)) prev_len = cur_len # Find tokens that overlap with the slop phrase first_overlap = None for ti, (start, end) in enumerate(token_spans): if end <= idx: continue if start >= phrase_end: break if first_overlap is None: first_overlap = ti token_text = tokenizer.decode([gen_ids[ti]], skip_special_tokens=False) token_clean = token_text.strip().lower() if token_clean and token_clean not in _FTPO_STOP_WORDS: return ti, "advanced_past_stopword" if ti != first_overlap else "matched" if first_overlap is not None: return first_overlap, "fallback_stopword" return -1, "no_token_overlap" def _extract_ftpo_pairs(results, tokenizer, top_k=5, min_chosen_tokens=1): pairs = [] stats = { "detected": 0, "matched": 0, "advanced_past_stopword": 0, "fallback_stopword": 0, "phrase_not_found": 0, "no_token_overlap": 0, "no_score": 0, "empty_candidates": 0, "no_chosen": 0, "accepted": 0, } for ri, r in enumerate(results): slop_found = r.get("slop_found") or detect_slop(r["gen_text"]) if not slop_found: log(f" gen[{ri}]: no slop detected") continue n_scores = len(r.get("scores") or []) n_gen = len(r.get("gen_ids") or []) log(f" gen[{ri}]: {len(slop_found)} slop phrases, {n_gen} gen tokens, {n_scores} scores") for phrase in slop_found: stats["detected"] += 1 token_pos, reason = _find_ftpo_rejected_token_pos(r["gen_text"], r["gen_ids"], phrase, tokenizer) if reason in stats: stats[reason] += 1 # Debug: log each phrase's fate if token_pos < 0: log(f" '{phrase}': pos={token_pos} reason={reason}") continue if token_pos >= n_scores: stats["no_score"] += 1 log(f" '{phrase}': pos={token_pos} but only {n_scores} scores available (reason={reason})") continue rejected_id = r["gen_ids"][token_pos] score_at_pos = r["scores"][token_pos] if r["scores"] else [] if not score_at_pos: stats["empty_candidates"] += 1 log(f" '{phrase}': pos={token_pos} reason={reason} -- empty candidate list at this position") continue chosen_ids = [tid for tid in score_at_pos if tid != rejected_id][:top_k] if len(chosen_ids) < min_chosen_tokens: stats["no_chosen"] += 1 rejected_tok = tokenizer.decode([rejected_id]) log(f" '{phrase}': pos={token_pos} reason={reason} rejected='{rejected_tok}' " f"-- only {len(chosen_ids)} chosen (need {min_chosen_tokens}), " f"candidates={len(score_at_pos)}") continue stats["accepted"] += 1 rejected_tok = tokenizer.decode([rejected_id]) chosen_toks = [tokenizer.decode([tid]) for tid in chosen_ids[:3]] log(f" '{phrase}': pos={token_pos} reason={reason} " f"rejected='{rejected_tok}' chosen={chosen_toks}") pairs.append({"context_ids": r["prompt_ids"] + r["gen_ids"][:token_pos], "rejected_id": rejected_id, "chosen_ids": chosen_ids, "phrase": phrase, "prompt": r["prompt"]}) log(f" FTPO pair extraction summary: " f"detected={stats['detected']} accepted={stats['accepted']} " f"matched={stats['matched']} stopword_skip={stats['advanced_past_stopword']} " f"stopword_fallback={stats['fallback_stopword']} " f"phrase_not_found={stats['phrase_not_found']} no_overlap={stats['no_token_overlap']} " f"no_score={stats['no_score']} empty_candidates={stats['empty_candidates']} " f"no_chosen={stats['no_chosen']}") return pairs def _train_ftpo(model, tokenizer, pairs, output_dir, epochs=1, lr=1e-4, lora_rank=16, max_context_length=512, early_stop_wins=0.85): from peft import get_peft_model device = next(model.parameters()).device log(f"🧹 Training FTPO on {len(pairs)} pairs...") os.makedirs(output_dir, exist_ok=True) model = get_peft_model(model, _make_lora_config(lora_rank, model)) trainable = sum(p.numel() for p in model.parameters() if p.requires_grad) log(f" LoRA: {trainable/1e6:.1f}M trainable") margin, lambda_target, lambda_nontarget, tau_target = 2.0, 0.05, 0.4, 0.5 optimizer = torch.optim.AdamW([p for p in model.parameters() if p.requires_grad], lr=lr) model.eval() ref_logits_cache = {} log(" Caching reference logits...") for pi, pair in enumerate(pairs): ctx_ids = pair["context_ids"][-max_context_length:] ctx = torch.tensor([ctx_ids], dtype=torch.long, device=device) with model.disable_adapter(), torch.no_grad(): ref_logits_cache[pi] = model(ctx).logits[0, -1, :].detach() del ctx model.train() for epoch in range(epochs): total_loss = 0.0 total_chosen, total_wins = 0, 0 for pi, pair in enumerate(pairs): ctx_ids = pair["context_ids"][-max_context_length:] ctx = torch.tensor([ctx_ids], dtype=torch.long, device=device) logits = model(ctx).logits[0, -1, :] ref = ref_logits_cache[pi] r_id, c_ids = pair["rejected_id"], pair["chosen_ids"] target_ids = c_ids + [r_id] chosen_idx = torch.tensor(c_ids, dtype=torch.long, device=logits.device) deltas = logits[chosen_idx] - logits[r_id] total_wins += (deltas.detach() > 0).sum().item() total_chosen += len(c_ids) weights = torch.clamp((margin - deltas) / margin, 0.0, 1.0) pref_loss = (torch.nn.functional.softplus(margin - deltas) * weights).sum() / max(len(c_ids), 1) target_idx = torch.tensor(target_ids, dtype=torch.long, device=logits.device) target_diffs = torch.clamp((logits[target_idx] - ref[target_idx]).abs() - tau_target, min=0.0).square() nontarget_mask = torch.ones(logits.shape[0], dtype=torch.bool, device=logits.device) nontarget_mask[target_idx] = False loss = pref_loss + lambda_target * target_diffs.mean() + lambda_nontarget * ((logits[nontarget_mask] - ref[nontarget_mask]) ** 2).mean() optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() del ctx, logits gc.collect() chosen_win = total_wins / max(total_chosen, 1) log(f" Epoch {epoch+1}/{epochs} | loss={total_loss / max(len(pairs), 1):.4f} | chosen_win={chosen_win:.3f}") if _CANCEL: log("⏹️ FTPO cancelled") break if MAX_TRAINING_TIME and time.time() - _START > MAX_TRAINING_TIME: log(f"⏰ FTPO timed out after {MAX_TRAINING_TIME//3600}h") break if early_stop_wins and chosen_win >= early_stop_wins: log(f" Early stop: chosen_win={chosen_win:.3f} >= {early_stop_wins:.3f}") break model.save_pretrained(output_dir) tokenizer.save_pretrained(output_dir) log(f" FTPO LoRA saved to {output_dir}") del model, optimizer, ref_logits_cache gc.collect() return output_dir def step_deslop(model=None, tokenizer=None, output_dir="/tmp/pipeline/output/deslop_lora", n_prompts=_FTPO_DEFAULT_PROMPTS, ftpo_epochs=1, lora_rank=16): result = {"adapter_dir": None} if model is None: raise ValueError("Deslop requires a loaded model") os.makedirs(output_dir, exist_ok=True) log(f" Slop phrase source: {SLOP_PHRASES_SOURCE} ({len(SLOP_PHRASES)} phrases)") log(f"🧹 FTPO deslop: generating baseline on {n_prompts} prompts...") prompts = _load_deslop_prompts(n_prompts) generations = _generate_baseline(model, tokenizer, prompts, max_new_tokens=_FTPO_PAIR_MAX_NEW_TOKENS) for g in generations: g["slop_found"] = detect_slop(g["gen_text"]) total_slop = sum(len(g["slop_found"]) for g in generations) log(f" Total slop: {total_slop} across {len(generations)} generations") if total_slop == 0: log(" ⚠️ No slop detected in model output, nothing to train on") del generations gc.collect() return result pairs = _extract_ftpo_pairs(generations, tokenizer, top_k=4, min_chosen_tokens=1) log(f" Extracted {len(pairs)} FTPO pairs from {total_slop} detected slop instances") if len(pairs) == 0: log(" WARNING: Could not extract FTPO pairs from detected slop.") log(" Diagnostics: Check logs above for per-phrase rejection reasons.") # Log a sample of what we got for debugging for gi, g in enumerate(generations): if g.get("slop_found"): n_scores = len(g.get("scores", [])) n_gen = len(g.get("gen_ids", [])) log(f" gen[{gi}]: text={g['gen_text'][:80]!r}... " f"gen_tokens={n_gen} scores={n_scores} slop={g['slop_found'][:3]}") del generations gc.collect() return result pairs_json = [{k: v for k, v in p.items() if k != "context_ids"} for p in pairs] with open(os.path.join(output_dir, "ftpo_pairs.json"), "w") as f: json.dump(pairs_json, f, indent=2) result["adapter_dir"] = _train_ftpo(model, tokenizer, pairs, output_dir, epochs=ftpo_epochs, lora_rank=lora_rank) del generations gc.collect() return result # ╔════════════════════════════════════════════════════════════╗ # ║ STEP 2: SFT LoRA (TRL) ║ # ╚════════════════════════════════════════════════════════════╝ def step_sft(model, tokenizer, samples, output_dir, lora_rank=16, epochs=1, learning_rate=2e-4, max_length=1024): from peft import get_peft_model from trl import SFTConfig, SFTTrainer from datasets import Dataset from transformers import TrainerCallback if not samples: raise ValueError("Empty dataset") model = get_peft_model(model, _make_lora_config(lora_rank, model)) trainable = sum(p.numel() for p in model.parameters() if p.requires_grad) total = sum(p.numel() for p in model.parameters()) tip = "" if len(samples) < 200 and epochs <= 1: tip = " (tip: increase to 2-3 epochs with < 200 samples)" log(f"🎓 SFT: {len(samples)} samples, LoRA {trainable/1e6:.1f}M/{total/1e9:.1f}B, epochs={epochs}, rank={lora_rank}, seq={max_length}, lr={learning_rate}{tip}") dataset = Dataset.from_list(samples) on_gpu = next(model.parameters()).device.type == "cuda" sft_config = SFTConfig( output_dir=output_dir, num_train_epochs=epochs, per_device_train_batch_size=1, gradient_accumulation_steps=min(16, max(1, len(samples) // 4)) if not on_gpu else 4, gradient_checkpointing=True, gradient_checkpointing_kwargs={"use_reentrant": False}, learning_rate=learning_rate, max_length=max_length, use_cpu=not on_gpu, bf16=on_gpu, fp16=False, optim="adafactor", logging_steps=1, save_strategy="no", report_to="none", dataloader_num_workers=0, warmup_steps=1, lr_scheduler_type="cosine", packing=False, loss_type="chunked_nll", ) class _LogCB(TrainerCallback): _train_start = None def on_log(self, args, state, control, logs=None, **kwargs): if logs and "loss" in logs: epoch = state.epoch or 0 eta = "" if self._train_start and state.global_step > 0 and state.max_steps > 0: elapsed = time.time() - self._train_start remaining = (elapsed / state.global_step) * (state.max_steps - state.global_step) if remaining >= 3600: eta = f" | ETA: {remaining//3600:.0f}h{(remaining%3600)//60:02.0f}m" elif remaining >= 60: eta = f" | ETA: {remaining/60:.0f}m" else: eta = f" | ETA: {remaining:.0f}s" if MAX_TRAINING_TIME and remaining > MAX_TRAINING_TIME: eta += f" (max {MAX_TRAINING_TIME//3600}h training then save, duplicate for no limit)" log(f" 📈 Step {state.global_step}/{state.max_steps} | epoch {epoch:.1f}/{args.num_train_epochs} | loss={logs['loss']:.4f}{eta}") def on_step_end(self, args, state, control, **kwargs): if _CANCEL: log("⏹️ Training cancelled") control.should_training_stop = True elif MAX_TRAINING_TIME and self._train_start and time.time() - self._train_start > MAX_TRAINING_TIME: log(f"⏰ Training timed out after {MAX_TRAINING_TIME//3600}h") control.should_training_stop = True def on_train_begin(self, *a, **kw): self._train_start = time.time() log("🚀 Training started") def on_train_end(self, *a, **kw): elapsed = time.time() - self._train_start if self._train_start else 0 if elapsed >= 60: log(f"✅ Training complete ({elapsed/60:.1f}min)") else: log(f"✅ Training complete ({elapsed:.0f}s)") trainer = SFTTrainer(model=model, args=sft_config, train_dataset=dataset, processing_class=tokenizer, callbacks=[_LogCB()]) try: trainer.train() except (MemoryError, RuntimeError) as e: err = str(e).lower() if "out of memory" in err or "alloc" in err or isinstance(e, MemoryError): import psutil rss = psutil.Process().memory_info().rss / 1e9 log(f"❌ OOM during training (RSS={rss:.1f}GB, max_seq={max_length}, rank={lora_rank})") log(f" 💡 Try: reduce Max seq (currently {max_length}), reduce LoRA rank (currently {lora_rank}), or use a smaller/4-bit model") raise adapter_dir = os.path.join(output_dir, "lora_adapter") os.makedirs(adapter_dir, exist_ok=True) model.save_pretrained(adapter_dir) tokenizer.save_pretrained(adapter_dir) size_mb = sum(f.stat().st_size for f in Path(adapter_dir).rglob("*") if f.is_file()) / 1e6 log(f"💾 Adapter saved: {adapter_dir} ({size_mb:.1f} MB)") return adapter_dir # ╔════════════════════════════════════════════════════════════╗ # ║ PIPELINE RUNNER ║ # ╚════════════════════════════════════════════════════════════╝ def run_pipeline(model_id, file_obj, hf_dataset_id, do_dsl, do_sft, lora_rank, lr, epochs, max_seq, progress=gr.Progress(track_tqdm=True)): global _PHASE, _START, _CANCEL if _PHASE != "idle": gr.Warning("Training is already running. Click 'Stop Training' first.") return None if _CHAT_MODEL is not None: gr.Warning("Unload the chat model first (training needs all available memory).") return None _CANCEL = False if not (do_dsl or do_sft): gr.Warning("Select at least one step to run.") return None if do_sft and file_obj is None and not (hf_dataset_id and hf_dataset_id.strip()): gr.Warning("SFT needs a dataset -- upload a file or enter a HuggingFace dataset ID.") return None if not model_id or not model_id.strip(): gr.Warning("Enter a model ID.") return None _PHASE = "running" _START = time.time() model_id = model_id.strip() or DEFAULT_MODEL use_4bit = _is_4bit(model_id) try: out = os.path.join(WORK_DIR, "output") if os.path.exists(out): shutil.rmtree(out) os.makedirs(out, exist_ok=True) samples = None if do_sft: samples = [] _dataset_fmt = "unknown" if file_obj is not None: samples.extend(_parse_dataset(file_obj, max_seq=int(max_seq))) log(f"📄 {len(samples)} samples from uploaded file(s)") if samples and "messages" in samples[0]: s0 = samples[0]["messages"] if any(isinstance(m, dict) and "from" in m for m in s0): _dataset_fmt = "sharegpt" else: _dataset_fmt = "chat" if hf_dataset_id and hf_dataset_id.strip(): from datasets import load_dataset as _ld _hf_id = hf_dataset_id.strip() _hf_split = "train" _hf_revision = None _hf_data_files = None if _hf_id.startswith("https://huggingface.co/"): _hf_id = _hf_id.replace("https://huggingface.co/datasets/", "").replace("https://huggingface.co/", "") if "/tree/" in _hf_id: _hf_id, _hf_revision = _hf_id.split("/tree/", 1) _hf_revision = _hf_revision.replace("%2F", "/") if "/blob/" in _hf_id: parts = _hf_id.split("/blob/", 1) _hf_id = parts[0] blob_rest = parts[1].replace("%2F", "/") if "/" in blob_rest: _hf_revision, _hf_data_files = blob_rest.split("/", 1) else: _hf_revision = blob_rest if "[" in _hf_id: _hf_id, _slice = _hf_id.split("[", 1) _hf_split = f"train[{_slice}" if not _slice.startswith(":") else f"train[{_slice}" if not _hf_revision and ":" in _hf_id and _hf_id.count("/") <= 1: _hf_id, _hf_revision = _hf_id.rsplit(":", 1) _hf_revision = _hf_revision.replace("%2F", "/") _ld_kwargs = {} if _hf_revision: _ld_kwargs["revision"] = _hf_revision if _hf_data_files: _ld_kwargs["data_files"] = _hf_data_files try: ds = _ld(_hf_id.strip(), split=_hf_split, **_ld_kwargs) except Exception: _local = os.path.join(os.path.dirname(os.path.abspath(__file__)), "humanize_data_300qa.jsonl") if os.path.isfile(_local) and "llm-trainer" in _hf_id: ds = _ld("json", data_files=_local, split=_hf_split) log(f"📄 Loaded bundled dataset from {_local}") else: raise if "messages" in ds.column_names: hf_samples = [{"messages": row["messages"]} for row in ds] _dataset_fmt = "chat" elif "conversations" in ds.column_names: hf_samples = [] for row in ds: msgs = [] if row.get("system"): msgs.append({"role": "system", "content": str(row["system"])}) for m in row["conversations"]: role = _SHAREGPT_ROLE_MAP.get(m.get("from", ""), m.get("from", "user")) msgs.append({"role": role, "content": m.get("value", m.get("content", ""))}) hf_samples.append({"messages": msgs}) _dataset_fmt = "sharegpt" elif "instruction" in ds.column_names: hf_samples = [] for row in ds: msgs = [] if row.get("system"): msgs.append({"role": "system", "content": str(row["system"])}) hist = row.get("history") if hist: for pair in hist: if isinstance(pair, (list, tuple)) and len(pair) >= 2: msgs.append({"role": "user", "content": str(pair[0])}) msgs.append({"role": "assistant", "content": str(pair[1])}) user_content = str(row.get("instruction", "") or "") inp = row.get("input") if inp is not None and str(inp) not in ("", "nan"): user_content += "\n" + str(inp) msgs.append({"role": "user", "content": user_content}) msgs.append({"role": "assistant", "content": str(row.get("output", ""))}) hf_samples.append({"messages": msgs}) _dataset_fmt = "alpaca" else: for c in ["text", "content", "completion"]: if c in ds.column_names: hf_samples = [{"messages": [{"role": "user", "content": ""}, {"role": "assistant", "content": str(row[c])}]} for row in ds] break else: raise ValueError(f"Can't auto-detect HF dataset format. Columns: {ds.column_names[:10]}") samples.extend(hf_samples) log(f"📄 {len(hf_samples)} samples from HF: {_hf_id} [{_hf_split}] (format: {_dataset_fmt})") if file_obj and hf_dataset_id and hf_dataset_id.strip(): log(f" 📄 Combined: {len(samples)} total samples (uploaded + HF dataset merged)") _mdl, _tok = None, None def _ensure_model(): nonlocal _mdl, _tok if _mdl is not None: return _mdl, _tok = _load_model(model_id, load_in_4bit=use_4bit) if do_dsl: _PHASE = "Step 1: Deslop" progress(0.35, "Step 1: Deslop") log("🧹 " + "STEP 1: DESLOP") _ensure_model() deslop_out = os.path.join(out, "deslop_lora") d = step_deslop(model=_mdl, tokenizer=_tok, output_dir=deslop_out) if d.get("adapter_dir"): log(f"Step 1 done -> FTPO LoRA at {d['adapter_dir']}") from peft import PeftModel as _PeftModel _mdl = _PeftModel.from_pretrained(_mdl, d["adapter_dir"]) _mdl = _mdl.merge_and_unload() log(" Merged deslop LoRA into model for step stacking") else: log("Step 1 done -> no FTPO pairs found, skipped") if not do_sft: del _mdl, _tok _mdl, _tok = None, None gc.collect() if _CANCEL: log("Cancelled after Step 1") return None if do_sft: _PHASE = "Step 2: SFT" progress(0.60, "Step 2: SFT LoRA") log("🎓 " + "STEP 2: SFT LoRA") _, est_breakdown = _estimate_ram(model_id, lora_rank=int(lora_rank), max_seq=int(max_seq)) log(f" 📊 RAM estimate: {est_breakdown}") _ensure_model() if not getattr(_tok, "chat_template", None): _ALPACA_TPL = "{% for message in messages %}{% if message['role'] == 'user' %}### Instruction:\n{{ message['content'] }}\n\n{% elif message['role'] == 'assistant' %}### Response:\n{{ message['content'] }}\n\n{% endif %}{% endfor %}{% if add_generation_prompt %}### Response:\n{% endif %}" _SHAREGPT_TPL = "{% for message in messages %}{% if message['role'] == 'user' %}Human: {{ message['content'] }}\n{% elif message['role'] == 'assistant' %}Assistant: {{ message['content'] }}\n{% endif %}{% endfor %}{% if add_generation_prompt %}Assistant: {% endif %}" _CHATML_TPL = "{% for message in messages %}{{ '<|im_start|>' + message['role'] + '\n' + message['content'] + '<|im_end|>\n' }}{% endfor %}{% if add_generation_prompt %}{{ '<|im_start|>assistant\n' }}{% endif %}" if _dataset_fmt == "alpaca": _tok.chat_template = _ALPACA_TPL log(" ⚠️ No chat_template, using Alpaca (matched dataset)") elif _dataset_fmt == "sharegpt": _tok.chat_template = _SHAREGPT_TPL log(" ⚠️ No chat_template, using ShareGPT (matched dataset)") else: _tok.chat_template = _CHATML_TPL log(" ⚠️ No chat_template, using ChatML (last resort)") sft_out = os.path.join(out, "sft_lora") adapter = step_sft(model=_mdl, tokenizer=_tok, samples=samples, output_dir=sft_out, lora_rank=int(lora_rank), epochs=int(epochs), learning_rate=float(lr), max_length=int(max_seq)) log(f"Step 2 done -> {adapter}") del _mdl, _tok _mdl, _tok = None, None gc.collect() _PHASE = "Packaging" progress(0.90, "Packaging") log("📦 Creating archive...") _model_slug = model_id.split("/")[-1] if "/" in model_id else model_id for _suf in ["-unsloth-bnb-4bit", "-unsloth", "-bnb-4bit"]: _model_slug = _model_slug.replace(_suf, "") zpath = os.path.join(WORK_DIR, f"lora_{_model_slug}.zip") with zipfile.ZipFile(zpath, "w", zipfile.ZIP_DEFLATED) as zf: for step_name in ["deslop_lora", "sft_lora"]: step_dir = os.path.join(out, step_name) if os.path.isdir(step_dir): for root, _, files in os.walk(step_dir): for f in files: fp = os.path.join(root, f) arc = os.path.join(step_name, os.path.relpath(fp, step_dir)) zf.write(fp, arc) zf.writestr("README.md", f"# Pipeline Output\nModel: {model_id}\n" "Load LoRA: `PeftModel.from_pretrained(base, './sft_lora')`\n") log(f"Archive: {os.path.getsize(zpath)/1e6:.1f} MB") progress(1.0, "Done") log("✅ DONE!") return zpath except Exception as e: log(f"❌ ERROR: {e}") import traceback log(traceback.format_exc()) gr.Warning(f"Pipeline failed: {e}") return None finally: _PHASE = "idle" _mdl = None _tok = None gc.collect() if torch.cuda.is_available(): torch.cuda.empty_cache() # ╔════════════════════════════════════════════════════════════╗ # ║ CHAT / INFERENCE ║ # ╚════════════════════════════════════════════════════════════╝ _CHAT_MODEL = None _CHAT_TOK = None def chat_load(model_id, lora_name): global _CHAT_MODEL, _CHAT_TOK if _PHASE != "idle": gr.Warning("Training is running. Stop training first to load a chat model.") return "Training in progress — stop it first" model_id = model_id.strip() or DEFAULT_MODEL if _CHAT_MODEL is not None: _CHAT_MODEL, _CHAT_TOK = None, None gc.collect() use_4bit = _is_4bit(model_id) _CHAT_MODEL, _CHAT_TOK = _load_model(model_id, load_in_4bit=use_4bit) if lora_name and lora_name != "(none)": from peft import PeftModel lora_path = os.path.join(WORK_DIR, lora_name) if "/" in lora_name else os.path.join(LORA_DIR, lora_name) lora_path = os.path.realpath(lora_path) if not (lora_path.startswith(os.path.realpath(WORK_DIR)) or lora_path.startswith(os.path.realpath(LORA_DIR))): return "Invalid LoRA path" _CHAT_MODEL = PeftModel.from_pretrained(_CHAT_MODEL, lora_path) _CHAT_MODEL.eval() if hasattr(_CHAT_MODEL, "gradient_checkpointing_disable"): _CHAT_MODEL.gradient_checkpointing_disable() return f"Loaded: {model_id} + {lora_name}" def chat_respond(message, history, model_id, lora_name, system_prompt="", thinking=False): history = history or [] if not message or not message.strip(): return history, "" try: if _PHASE != "idle": gr.Warning("Training is running. Wait for it to finish or stop it first.") return history, message if _CHAT_MODEL is None or _CHAT_TOK is None: status = chat_load(model_id, lora_name) if _CHAT_MODEL is None: history.append({"role": "user", "content": message}) history.append({"role": "assistant", "content": f"Failed to load model: {status}"}) return history, "" history.append({"role": "user", "content": message}) msgs = [] if system_prompt and system_prompt.strip(): msgs.append({"role": "system", "content": system_prompt.strip()}) msgs.extend(m for m in history if m.get("content")) text = _apply_chat_template(_CHAT_TOK, msgs, thinking=thinking) inputs = _CHAT_TOK(text, return_tensors="pt", truncation=True, max_length=4096) device = next(_CHAT_MODEL.parameters()).device inputs = {k: v.to(device) for k, v in inputs.items()} from transformers import TextIteratorStreamer streamer = TextIteratorStreamer(_CHAT_TOK, skip_prompt=True, skip_special_tokens=True) gen_kwargs = dict(**inputs, max_new_tokens=2048, do_sample=True, temperature=0.8, top_p=0.9, repetition_penalty=1.1, streamer=streamer) t = threading.Thread(target=lambda: _CHAT_MODEL.generate(**gen_kwargs), daemon=True) t.start() history.append({"role": "assistant", "content": ""}) partial = "" for chunk in streamer: partial += chunk if thinking and partial.startswith("thought\n"): think_end = partial.find("\n\n", 10) if think_end > 0: lines = partial[:think_end].split("\n", 1) think_text = lines[1] if len(lines) > 1 else "" answer = partial[think_end:].strip() history[-1]["content"] = f"
💭 Thinking...\n\n{think_text}\n\n
\n\n{answer}" else: history[-1]["content"] = partial elif not thinking and partial.startswith("thought\n"): idx = partial.find("\n\n", 10) history[-1]["content"] = partial[idx:].strip() if idx > 0 else partial else: history[-1]["content"] = partial yield history, "" del inputs if not partial: history[-1]["content"] = "(empty response)" yield history, "" print(f"[chat] user: {message}", flush=True) print(f"[chat] assistant: {partial[:200]}{'...' if len(partial) > 200 else ''}", flush=True) except Exception as e: log(f"❌ Chat error: {e}") import traceback log(traceback.format_exc()) gr.Warning(f"Chat failed: {e}") if not history or history[-1].get("role") != "user" or history[-1].get("content") != message: history.append({"role": "user", "content": message}) history.append({"role": "assistant", "content": f"Chat failed: {e}"}) yield history, "" # ╔════════════════════════════════════════════════════════════╗ # ║ GRADIO UI ║ # ╚════════════════════════════════════════════════════════════╝ _MODEL_CHOICES = [DEFAULT_MODEL, "unsloth/gemma-4-E4B-it", "unsloth/Qwen3.5-0.8B", "unsloth/Qwen3.5-2B", "unsloth/Qwen3.5-4B"] try: from huggingface_hub import HfApi as _HfApi _api = _HfApi() _seen = set() _filtered = [] _exclude_end = ("-gguf", "-mlx", "-fp8", "-fp8-dynamic") _exclude_word = {"gguf", "mlx", "nvfp4", "fp8"} for _m in list(_api.list_models(author="unsloth", sort="lastModified", num_parameters="min:0,max:12000000000", limit=600)): if not _m.id or _m.id in _seen: continue _low = _m.id.lower().split("/")[-1] if any(_low.endswith(x) for x in _exclude_end): continue if set(_low.replace("-", " ").replace("_", " ").split()) & _exclude_word: continue _seen.add(_m.id) _filtered.append(_m.id) if len(_filtered) > 3: _MODEL_CHOICES = _filtered if DEFAULT_MODEL not in _MODEL_CHOICES: _MODEL_CHOICES.insert(0, DEFAULT_MODEL) print(f"[startup] Fetched {len(_filtered)} models from unsloth (showing {len(_MODEL_CHOICES)})", flush=True) except Exception as _e: print(f"[startup] Model fetch failed: {_e}", flush=True) CSS = """ .gradio-container * { gap: 0 !important; } .row.unequal-height { flex-wrap: nowrap !important; } #adv .block { min-width: 0 !important; padding: 2px 4px !important; border: none !important; } #adv .column { gap: 0 !important; } #adv .row { gap: 0 !important; } #adv .label-wrap { width: 100% !important; } #lr-slider input[type="number"]::-webkit-inner-spin-button, #lr-slider input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none !important; margin: 0 !important; } #lr-slider input[type="number"] { -moz-appearance: textfield !important; width: 100% !important; } #adv button { padding: 0 2px !important; min-width: 0 !important; width: auto !important; margin: 0 !important; } #adv .head { gap: 2px !important; } #adv input[type="range"] { width: 100% !important; } #adv input[type="range"] { width: 60px !important; min-width: 40px !important; } #adv .row { flex-wrap: nowrap !important; } .gradio-container > div, .gradio-container > div > div, .gradio-container > div > div > div, .gradio-container > div > div > div > div { padding-top: 0 !important; padding-bottom: 0 !important; margin-top: 0 !important; margin-bottom: 0 !important; } #log-box textarea { font-family: monospace !important; font-size: 12px !important; line-height: 1.4 !important; } #log-box { border: 2px solid #666 !important; border-radius: 8px !important; height: 340px !important; } #log-box textarea { height: 300px !important; overflow-y: auto !important; } #chat-box { height: 340px !important; } #hdr { gap: 8px !important; padding: 0 !important; margin: 0 !important; min-height: 28px !important; max-height: 32px !important; align-items: center !important; overflow: visible !important; } #hdr > div:first-child { flex: 1 1 0 !important; overflow: hidden !important; white-space: nowrap !important; text-overflow: ellipsis !important; } #model-dd { min-width: 260px !important; flex: 0 0 260px !important; } #hdr * { padding-top: 0 !important; padding-bottom: 0 !important; margin-top: 0 !important; margin-bottom: 0 !important; min-height: 0 !important; } #hdr p { line-height: 28px !important; font-size: 14px !important; } #hdr input { height: 28px !important; } #hdr .wrap { border: 1px solid var(--border-color-primary) !important; border-radius: 6px !important; } .tabs, .tab-wrapper, div:has(> .tab-nav) { margin: 0 !important; padding: 0 !important; gap: 0 !important; } .tab-nav { margin: 0 !important; padding: 0 !important; } .tabitem { padding-top: 2px !important; } #hdr + div, #hdr ~ div { margin-top: 0 !important; padding-top: 0 !important; } .block { padding: 4px !important; min-width: 0 !important; } .block .wrap { padding: 4px 6px !important; min-height: 0 !important; } .block label { margin-bottom: 2px !important; font-size: 13px !important; } .block .upload-container { padding: 4px !important; min-height: 0 !important; max-height: 60px !important; overflow: hidden !important; } .block .upload-container .wrap { padding: 4px !important; } .file-preview { max-height: 60px !important; } .block:has(input[type="file"]) { max-height: 80px !important; overflow: hidden !important; } #file-up, #file-dl { max-height: 70px !important; overflow: hidden !important; } .file-preview { font-size: 11px !important; width: 100% !important; table-layout: fixed !important; } .block textarea, .block input[type="text"] { padding: 4px 8px !important; font-size: 13px !important; } .contain > .gap { gap: 2px !important; } footer { display: none !important; } .column { gap: 2px !important; } .tabitem .row > .column > .column { gap: 2px !important; } .tabitem .row > .column > .column > * { flex: 1 !important; } .group { gap: 0 !important; } input[type="checkbox"] { margin-right: 6px !important; } #model-dd .wrap { border: 1px solid var(--color-accent) !important; border-radius: 6px !important; } #model-dd input:focus { outline: none !important; box-shadow: 0 0 0 1px var(--color-accent) !important; } #model-dd .secondary-wrap { position: relative !important; } #model-dd.model-dd-empty .secondary-wrap::before { content: "HF model ID (<12B)"; position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--body-text-color-subdued, rgba(255,255,255,0.4)); font-size: 14px; pointer-events: none; z-index: 1; } .secondary-wrap { min-height: 24px !important; height: 28px !important; } .secondary-wrap input { height: 24px !important; } .dropdown-container label, .block:has(.dropdown-arrow) > label, .dropdown-container .label-wrap, .block:has(.dropdown-arrow) .label-wrap { padding: 2px 4px !important; font-size: 12px !important; margin: 0 !important; line-height: 1.2 !important; } """ MODEL_DD_PLACEHOLDER_JS = """ (() => { const syncModelDropdownPlaceholder = () => { const root = document.getElementById("model-dd"); if (!root) return; const input = root.querySelector("input"); root.classList.toggle("model-dd-empty", !input || input.value.trim() === ""); }; const scheduleSync = () => requestAnimationFrame(syncModelDropdownPlaceholder); ["input", "change", "focus", "blur", "keyup", "click"].forEach((eventName) => { document.addEventListener(eventName, (event) => { if (event.target?.closest?.("#model-dd")) scheduleSync(); }, true); }); new MutationObserver(scheduleSync).observe(document.body, { childList: true, subtree: true }); scheduleSync(); // Reformat LR slider: number input + min/max labels to scientific notation const toSci = (v) => { const n = parseFloat(v); if (!n || n >= 0.01) return v; const exp = Math.floor(Math.log10(n)); const m = n / Math.pow(10, exp); return m === 1 ? `1e${exp}` : `${Math.round(m)}e${exp}`; }; const formatLR = () => { const slider = document.getElementById("lr-slider"); if (!slider) return; // Reformat number input const input = slider.querySelector('input[type="number"]'); if (input && document.activeElement !== input) { const val = parseFloat(input.value); if (val && val < 0.01) input.value = toSci(val); } // Reformat min/max labels slider.querySelectorAll('span').forEach(s => { const v = parseFloat(s.textContent); if (v && v < 0.01) s.textContent = toSci(v); }); }; setInterval(formatLR, 500); // Rotate HF Dataset ID placeholder const dsHints = ["[author]/[dataset]/[PathToFile]:[branch][:sample-count]", "HuggingFaceH4/no_robots[:500]"]; let dsIdx = 0; setInterval(() => { const el = document.querySelector("#hf-ds textarea, #hf-ds input"); if (el && !el.value) { dsIdx = (dsIdx + 1) % dsHints.length; el.setAttribute("placeholder", dsHints[dsIdx]); } }, 4000); })(); """ with gr.Blocks(title="LLM Training Pipeline") as demo: with gr.Row(elem_id="hdr"): gr.HTML("

SFT LoRA CPU training & inference + Deslop

", padding=True) model_input = gr.Dropdown(value=DEFAULT_MODEL, allow_custom_value=True, show_label=False, container=False, choices=_MODEL_CHOICES, scale=1, elem_id="model-dd") with gr.Tabs(): with gr.Tab("🏋️ Training"): with gr.Row(equal_height=False): with gr.Column(scale=1, min_width=0): file_in = gr.File(label="Dataset (.jsonl/.csv/.txt)", file_types=[".jsonl", ".json", ".csv", ".parquet", ".txt"], file_count="multiple", height=50, elem_id="file-up") hf_dataset = gr.Textbox(label="HF Dataset ID", value="Luminia/llm-trainer", placeholder="HuggingFaceH4/no_robots[:500]", elem_id="hf-ds") dl = gr.File(label="Download LoRA", interactive=False, height=50, elem_id="file-dl") with gr.Row(): do_dsl = gr.Checkbox(label="Deslop", value=False, info="FTPO AI slop phrases removal") do_sft = gr.Checkbox(label="SFT LoRA", value=True, info="Custom dataset: TRL SFTTrainer") with gr.Accordion("Advanced", open=False, elem_id="adv"): with gr.Row(): ep = gr.Slider(1, 10, 1, step=1, label="Epochs") rank = gr.Slider(4, 128, 16, step=4, label="LoRA rank") with gr.Row(): lr = gr.Slider(1e-5, 5e-4, 2e-4, step=1e-5, label="LR", info="slow↔fast", elem_id="lr-slider") seq = gr.Slider(64, 4096, 1024, step=64, label="Max seq") with gr.Row(): run_btn = gr.Button("Start Training", variant="primary", size="lg", scale=3) cancel_btn = gr.Button("Stop Training", variant="stop", size="lg", visible=False, scale=1) with gr.Column(scale=2, min_width=0): log_box = gr.Textbox(label="Log", lines=14, interactive=False, autoscroll=True, elem_id="log-box") with gr.Tab("💬 Chat"): with gr.Row(equal_height=False): with gr.Column(scale=1, min_width=0): chat_lora = gr.Dropdown(choices=_list_loras(), value="(none)", label="LoRA adapter") chat_input = gr.Textbox(label="Message", placeholder="Type a message...", lines=3) with gr.Row(): chat_send = gr.Button("Send", variant="secondary", scale=1, min_width=0) chat_think = gr.Checkbox(label="Thinking", value=False, scale=0, min_width=0, container=False) chat_status = gr.Textbox(value="Send a message to auto-load", interactive=False, show_label=False, container=False) chat_sys = gr.Textbox(placeholder="System prompt (optional)", show_label=False, container=False, lines=1, max_lines=1) with gr.Column(scale=2, min_width=0): chatbot = gr.Chatbot(show_label=False, elem_id="chat-box") chat_lora.focus(fn=lambda: gr.update(choices=_list_loras()), outputs=[chat_lora]) chat_lora.change(fn=chat_load, inputs=[model_input, chat_lora], outputs=[chat_status], concurrency_limit=1) chat_send.click(fn=chat_respond, inputs=[chat_input, chatbot, model_input, chat_lora, chat_sys, chat_think], outputs=[chatbot, chat_input], concurrency_limit=1) chat_input.submit(fn=chat_respond, inputs=[chat_input, chatbot, model_input, chat_lora, chat_sys, chat_think], outputs=[chatbot, chat_input], concurrency_limit=1) _pipeline_result = [None] def _run_and_refresh(*args): logs = [] while not _LOG_Q.empty(): _LOG_Q.get_nowait() _pipeline_result[0] = None error = [None] def _worker(): try: _pipeline_result[0] = run_pipeline(*args) except Exception as e: error[0] = e t = threading.Thread(target=_worker, daemon=True) t.start() logs.append("⏳ Starting pipeline...") yield "\n".join(logs) try: while t.is_alive(): changed = False while not _LOG_Q.empty(): logs.append(_LOG_Q.get_nowait()) changed = True if changed: yield "\n".join(logs[-2000:]) time.sleep(0.5) except GeneratorExit: global _CANCEL _CANCEL = True log("⏹️ Browser disconnected, stopping training...") t.join(timeout=30) return while not _LOG_Q.empty(): logs.append(_LOG_Q.get_nowait()) yield "\n".join(logs[-2000:]) def _after_pipeline(): return _pipeline_result[0], gr.update(choices=_list_loras()), gr.update(visible=True), gr.update(visible=False) def _cancel_training(): global _CANCEL _CANCEL = True log("⏹️ Cancellation requested") return gr.update(visible=False), gr.update(visible=True) run_btn.click( fn=lambda: (gr.update(visible=False), gr.update(visible=True)), outputs=[run_btn, cancel_btn], api_name="_btn_swap", ).then( fn=_run_and_refresh, inputs=[model_input, file_in, hf_dataset, do_dsl, do_sft, rank, lr, ep, seq], outputs=[log_box], api_name="run_pipeline", concurrency_limit=1, ).then( fn=_after_pipeline, outputs=[dl, chat_lora, run_btn, cancel_btn], ) cancel_btn.click(fn=_cancel_training, outputs=[cancel_btn, run_btn], api_name="cancel_training") def _on_unload(): gc.collect() demo.unload(_on_unload) demo.queue(default_concurrency_limit=1) if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="LLM Training Pipeline: Deslop + SFT LoRA") parser.add_argument("--model", default=DEFAULT_MODEL, help="HuggingFace model ID") parser.add_argument("--dataset", help="Path to .jsonl/.csv/.parquet dataset") parser.add_argument("--hf-dataset", help="HuggingFace dataset ID (e.g. HuggingFaceH4/no_robots)") parser.add_argument("--deslop", action="store_true", help="Run step 1: deslop") parser.add_argument("--sft", action="store_true", help="Run step 2: SFT LoRA") parser.add_argument("--epochs", type=int, default=1) parser.add_argument("--lr", type=float, default=2e-4) parser.add_argument("--rank", type=int, default=16, help="LoRA rank") parser.add_argument("--max-seq", type=int, default=1024) parser.add_argument("--device", default="auto", help="Device: auto, cpu, cuda") args = parser.parse_args() import sys sys.argv = sys.argv[:1] if args.deslop or args.sft: if args.device == "auto": device = "cuda" if torch.cuda.is_available() else "cpu" else: device = args.device log(f"CLI mode: device={device}") if device == "cuda": import functools _this = sys.modules[__name__] _this._load_model = functools.partial(_load_model, device_map=device) class _FakeFile: def __init__(self, path): self.name = path result = run_pipeline( args.model, _FakeFile(args.dataset) if args.dataset else None, args.hf_dataset or "", args.deslop, args.sft, args.rank, args.lr, args.epochs, args.max_seq, ) if result: log(f"Output: {result}") else: log("Pipeline failed or no steps selected") else: demo.launch(server_name="0.0.0.0", server_port=7860, ssr_mode=False, theme=hz_theme, css=CSS + THEME_CSS, js=MODEL_DD_PLACEHOLDER_JS + THEME_JS, head=THEME_HEAD, show_error=True)