sapbot

LLM Fine-tune Studio

Feb 7th, 2026
43
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 37.15 KB | None | 0 0
  1. import gradio as gr
  2. import json
  3. import os
  4. import sys
  5. import torch
  6. import subprocess
  7. import shutil
  8. import glob
  9. import logging
  10. import threading
  11. import queue
  12. import time
  13. from datetime import datetime
  14.  
  15. # Setup logging to file for debugging crashes
  16. logging.basicConfig(
  17.     level=logging.INFO,
  18.     format='%(asctime)s - %(levelname)s - %(message)s',
  19.     handlers=[
  20.         logging.FileHandler("debug.log"),
  21.         logging.StreamHandler(sys.stderr)
  22.     ]
  23. )
  24. logger = logging.getLogger(__name__)
  25.  
  26. # ML Libraries
  27. from transformers import (
  28.     AutoModelForCausalLM,
  29.     AutoTokenizer,
  30.     BitsAndBytesConfig,
  31.     TrainingArguments,
  32. )
  33. from peft import LoraConfig, get_peft_model, PeftModel, TaskType, prepare_model_for_kbit_training
  34. from trl import SFTTrainer
  35. from datasets import Dataset
  36.  
  37. # ==================== Configuration ====================
  38. DEFAULT_MODEL = "Qwen/Qwen3-0.6B"
  39. MAX_SEQ_LENGTH = 2048
  40.  
  41. # ==================== Global State ====================
  42. class AppState:
  43.     def __init__(self):
  44.         self.current_model = None
  45.         self.current_tokenizer = None
  46.         self.base_model_id = None
  47.         self.adapter_path = None
  48.         self.dataset_path = None
  49.         self.is_training = False
  50.         self.training_logs = []
  51.         self.training_status_queue = queue.Queue()
  52.         self.system_prompt = None
  53.        
  54. state = AppState()
  55.  
  56. # ==================== Dataset Utilities ====================
  57. def parse_conversation_format(text):
  58.     conversations = []
  59.     text = text.strip()
  60.    
  61.     try:
  62.         data = json.loads(text)
  63.         if isinstance(data, list) and len(data) > 0 and isinstance(data[0], dict) and "role" in data[0]:
  64.             conversations.append({"messages": data})
  65.         elif isinstance(data, list):
  66.             for conv in data:
  67.                 if isinstance(conv, list):
  68.                     conversations.append({"messages": conv})
  69.                 elif isinstance(conv, dict) and "messages" in conv:
  70.                     conversations.append(conv)
  71.     except json.JSONDecodeError:
  72.         for line in text.strip().split('\n'):
  73.             line = line.strip()
  74.             if not line:
  75.                 continue
  76.             try:
  77.                 data = json.loads(line)
  78.                 if isinstance(data, list):
  79.                     conversations.append({"messages": data})
  80.                 elif isinstance(data, dict) and "messages" in data:
  81.                     conversations.append(data)
  82.             except:
  83.                 continue
  84.    
  85.     return conversations
  86.  
  87. def formatting_prompts_func(example, tokenizer=None, system_prompt=None):
  88.     """Format using model's chat template if available, fallback to manual format."""
  89.     messages = example["messages"]
  90.    
  91.     # Prepend system prompt if provided and not already present
  92.     if system_prompt and len(messages) > 0:
  93.         if messages[0].get("role") != "system":
  94.             messages = [{"role": "system", "content": system_prompt}] + messages
  95.         else:
  96.             messages[0]["content"] = system_prompt
  97.    
  98.     logger.info(f"Formatting {len(messages)} messages")
  99.    
  100.     if tokenizer is not None and hasattr(tokenizer, "apply_chat_template") and tokenizer.chat_template is not None:
  101.         try:
  102.             text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
  103.             return {"text": text}
  104.         except Exception as e:
  105.             logger.warning(f"Chat template failed, using fallback: {e}")
  106.    
  107.     # Fallback to manual formatting
  108.     formatted = ""
  109.     for msg in messages:
  110.         role = msg.get("role", "user")
  111.         content = msg.get("content", "")
  112.         if role == "system":
  113.             formatted += f"System: {content}\n"
  114.         elif role == "user":
  115.             formatted += f"User: {content}\n"
  116.         elif role == "assistant":
  117.             formatted += f"Assistant: {content}\n"
  118.     formatted += "Assistant: "
  119.    
  120.     logger.info(f"Formatted text (first 100 chars): {formatted[:100]}...")
  121.     return {"text": formatted.strip()}
  122.  
  123. def create_baked_chat_template(original_template, system_prompt):
  124.     """
  125.    Create a new chat template that bakes in the system prompt.
  126.    Handles Jinja2 template modification properly.
  127.    """
  128.     if not system_prompt:
  129.         return original_template
  130.    
  131.     # Escape single quotes in system prompt for Jinja
  132.     escaped_prompt = system_prompt.replace("'", "\\'")
  133.    
  134.     # If there's no original template or it's empty, create a generic one
  135.     if not original_template:
  136.         # Generic chat template that works with most models
  137.         baked = (
  138.             "{% if messages[0]['role'] != 'system' %}"
  139.             "{% set messages = [{'role': 'system', 'content': '" + escaped_prompt + "'}] + messages %}"
  140.             "{% else %}"
  141.             "{% set messages[0]['content'] = '" + escaped_prompt + "' %}"
  142.             "{% endif %}"
  143.             "{% for message in messages %}"
  144.             "{% if message['role'] == 'system' %}"
  145.             "<|im_start|>system\n{{ message['content'] }}<|im_end|>\n"
  146.             "{% elif message['role'] == 'user' %}"
  147.             "<|im_start|>user\n{{ message['content'] }}<|im_end|>\n"
  148.             "{% elif message['role'] == 'assistant' %}"
  149.             "<|im_start|>assistant\n{{ message['content'] }}<|im_end|>\n"
  150.             "{% endif %}"
  151.             "{% endfor %}"
  152.             "{% if add_generation_prompt %}<|im_start|>assistant\n{% endif %}"
  153.         )
  154.         return baked
  155.    
  156.     # If there is an original template, we need to prepend our system prompt logic
  157.     # This injects the system message at the start if not present
  158.     baked = (
  159.         "{% if messages[0]['role'] != 'system' %}"
  160.         "{% set messages = [{'role': 'system', 'content': '" + escaped_prompt + "'}] + messages %}"
  161.         "{% else %}"
  162.         "{% set messages[0]['content'] = '" + escaped_prompt + "' %}"
  163.         "{% endif %}"
  164.         + original_template
  165.     )
  166.    
  167.     return baked
  168.  
  169. def modify_chat_template_for_baked_system_prompt(tokenizer, system_prompt):
  170.     """
  171.    Modify the tokenizer's chat template to permanently include the system prompt.
  172.    """
  173.     if not system_prompt:
  174.         return tokenizer
  175.    
  176.     try:
  177.         # Get the original chat template (might be None or empty)
  178.         original_template = tokenizer.chat_template
  179.        
  180.         # Create new baked template
  181.         baked_template = create_baked_chat_template(original_template, system_prompt)
  182.        
  183.         # Set the modified template
  184.         tokenizer.chat_template = baked_template
  185.        
  186.         logger.info(f"Modified chat template: {baked_template[:200]}...")
  187.        
  188.         # Also try to set as init_kwargs to ensure it gets saved
  189.         if hasattr(tokenizer, 'init_kwargs'):
  190.             tokenizer.init_kwargs['chat_template'] = baked_template
  191.            
  192.         return tokenizer
  193.        
  194.     except Exception as e:
  195.         logger.error(f"Failed to modify chat template: {e}")
  196.         import traceback
  197.         logger.error(traceback.format_exc())
  198.         return tokenizer
  199.  
  200. # ==================== Training Functions ====================
  201. def write_model_card(output_dir, model_id, timestamp, num_epochs, learning_rate, lora_r, system_prompt=None):
  202.     """Write README.md with YAML frontmatter, deleting any existing one first."""
  203.     readme_path = os.path.join(output_dir, "README.md")
  204.    
  205.     if os.path.exists(readme_path):
  206.         try:
  207.             os.remove(readme_path)
  208.             logger.info("Removed existing README.md")
  209.         except Exception as e:
  210.             logger.warning(f"Could not remove existing README: {e}")
  211.    
  212.     lines = [
  213.         "---",
  214.         "base_model:",
  215.         f"- {model_id}",
  216.         "pipeline_tag: text-generation",
  217.         "---",
  218.         "",
  219.         "# Model Card",
  220.         "",
  221.         f"This is a full fine-tuned model based on `{model_id}`.",
  222.         "",
  223.         "## Training Details",
  224.         "",
  225.         f"- **Base Model:** {model_id}",
  226.         f"- **Training Date:** {timestamp}",
  227.         f"- **Epochs:** {num_epochs}",
  228.         f"- **Learning Rate:** {learning_rate}",
  229.         f"- **LoRA Rank:** {lora_r} (merged into full weights)",
  230.     ]
  231.    
  232.     if system_prompt:
  233.         lines.extend([
  234.             "",
  235.             "## Baked-in System Prompt",
  236.             "",
  237.             f"This model has the following system prompt **baked into its chat template**:",
  238.             "",
  239.             f"> {system_prompt}",
  240.             "",
  241.             "**Important:** This system prompt is now part of the model's default behavior. It will be automatically applied in llama.cpp and other tools without needing to specify it explicitly."
  242.         ])
  243.    
  244.     lines.extend([
  245.         "",
  246.         "## Training Software",
  247.         "",
  248.         "It has been trained using [Romarchive's LLM Fine-tuning Studio](https://cows.info.gf/search?q=LLM%20Fine-tuning%20Studio).",
  249.         "",
  250.         "## Usage",
  251.         "",
  252.         "### Python (Transformers)",
  253.         "",
  254.         "```python",
  255.         "from transformers import AutoModelForCausalLM, AutoTokenizer",
  256.         "",
  257.         f"model = AutoModelForCausalLM.from_pretrained(\"{output_dir}\")",
  258.         f"tokenizer = AutoTokenizer.from_pretrained(\"{output_dir}\")",
  259.         "",
  260.         "# The system prompt is already baked in! Just use:",
  261.         "messages = [{\"role\": \"user\", \"content\": \"Hello!\"}]",
  262.         "text = tokenizer.apply_chat_template(messages, tokenize=False)",
  263.         "```",
  264.         "",
  265.         "### llama.cpp",
  266.         "",
  267.         "Simply load the GGUF file. The system prompt is baked into the model weights and chat template.",
  268.         "",
  269.         "```bash",
  270.         f"./main -m model.gguf --prompt \"Hello!\"",
  271.         "```"
  272.     ])
  273.    
  274.     readme_content = "\n".join(lines)
  275.    
  276.     try:
  277.         with open(readme_path, "w", encoding='utf-8') as f:
  278.             f.write(readme_content)
  279.             f.flush()
  280.             os.fsync(f.fileno())
  281.         logger.info(f"Successfully wrote README.md to {readme_path}")
  282.     except Exception as e:
  283.         logger.error(f"Failed to write README.md: {e}")
  284.         raise
  285.  
  286. def merge_and_save_model(base_model_id, adapter_path, output_dir, tokenizer, use_4bit, system_prompt=None):
  287.     """Merge LoRA adapter with base model and save full model with modified tokenizer."""
  288.     logger.info("Starting model merge process...")
  289.    
  290.     if use_4bit and torch.cuda.is_available():
  291.         logger.info("Reloading base model in FP16 for merging...")
  292.         base_model = AutoModelForCausalLM.from_pretrained(
  293.             base_model_id,
  294.             torch_dtype=torch.float16,
  295.             device_map="auto",
  296.             trust_remote_code=True,
  297.         )
  298.     else:
  299.         has_cuda = torch.cuda.is_available()
  300.         logger.info(f"Loading base model for merging (CUDA: {has_cuda})...")
  301.         base_model = AutoModelForCausalLM.from_pretrained(
  302.             base_model_id,
  303.             torch_dtype=torch.float16 if has_cuda else torch.float32,
  304.             device_map="auto" if has_cuda else "cpu",
  305.             trust_remote_code=True,
  306.         )
  307.    
  308.     logger.info("Loading adapter...")
  309.     model = PeftModel.from_pretrained(base_model, adapter_path)
  310.    
  311.     logger.info("Merging adapter with base model...")
  312.     model = model.merge_and_unload()
  313.    
  314.     logger.info("Saving merged model...")
  315.     model.save_pretrained(output_dir, safe_serialization=True)
  316.    
  317.     # CRITICAL: Modify tokenizer to bake in system prompt before saving
  318.     if system_prompt:
  319.         logger.info("Baking system prompt into tokenizer chat template...")
  320.         tokenizer = modify_chat_template_for_baked_system_prompt(tokenizer, system_prompt)
  321.        
  322.         # Save the system prompt separately as well
  323.         with open(os.path.join(output_dir, "baked_system_prompt.txt"), "w") as f:
  324.             f.write(system_prompt)
  325.    
  326.     # Explicitly save tokenizer config with chat template
  327.     tokenizer.save_pretrained(output_dir)
  328.    
  329.     # Verify tokenizer config was saved with chat template
  330.     tokenizer_config_path = os.path.join(output_dir, "tokenizer_config.json")
  331.     if os.path.exists(tokenizer_config_path):
  332.         with open(tokenizer_config_path, 'r') as f:
  333.             config = json.load(f)
  334.             if 'chat_template' in config and config['chat_template']:
  335.                 logger.info("Verified: tokenizer_config.json contains chat_template")
  336.             else:
  337.                 logger.warning("Warning: tokenizer_config.json missing chat_template, fixing...")
  338.                 # Force write it
  339.                 config['chat_template'] = tokenizer.chat_template
  340.                 with open(tokenizer_config_path, 'w') as f:
  341.                     json.dump(config, f, indent=2)
  342.    
  343.     if not os.path.exists(os.path.join(output_dir, "config.json")):
  344.         raise RuntimeError("Merged model config.json not found after saving!")
  345.    
  346.     del model
  347.     del base_model
  348.     if torch.cuda.is_available():
  349.         torch.cuda.empty_cache()
  350.    
  351.     logger.info("Model merge and save completed")
  352.  
  353. def train_model_thread(model_id, dataset_content, learning_rate, num_epochs, lora_r, lora_alpha,
  354.                        use_4bit, status_queue, system_prompt=None):
  355.     """Run training in a separate thread and put status updates in queue."""
  356.     output_dir = None
  357.     adapter_dir = None
  358.    
  359.     state.system_prompt = system_prompt
  360.    
  361.     def put_status(msg, path=None, download_visible=False):
  362.         status_queue.put((msg, path, download_visible))
  363.    
  364.     try:
  365.         state.is_training = True
  366.         logger.info(f"Starting training process for {model_id}")
  367.         if system_prompt:
  368.             logger.info(f"Baking in system prompt: {system_prompt[:100]}...")
  369.         put_status(f"Starting training for {model_id}...", None, False)
  370.        
  371.         timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  372.         output_dir = f"./finetuned_models/{model_id.replace('/', '_')}_{timestamp}_merged"
  373.         adapter_dir = f"./finetuned_models/{model_id.replace('/', '_')}_{timestamp}_adapter"
  374.        
  375.         os.makedirs(output_dir, exist_ok=True)
  376.         os.makedirs(adapter_dir, exist_ok=True)
  377.        
  378.         state.adapter_path = output_dir
  379.         logger.info(f"Output directory: {output_dir}")
  380.         logger.info(f"Adapter directory (temp): {adapter_dir}")
  381.        
  382.         conversations = parse_conversation_format(dataset_content)
  383.         if not conversations:
  384.             put_status("Error: No valid conversations found in dataset", None, False)
  385.             return
  386.        
  387.         if len(conversations) < 3:
  388.             logger.warning(f"Very small dataset detected ({len(conversations)} conversations).")
  389.             put_status(f"⚠️ Warning: Only {len(conversations)} conversations detected.", None, False)
  390.             time.sleep(2)
  391.            
  392.         dataset = Dataset.from_list(conversations)
  393.         logger.info(f"Loaded dataset with {len(conversations)} conversations")
  394.        
  395.         put_status("Loading tokenizer...", None, False)
  396.         tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
  397.         tokenizer.pad_token = tokenizer.eos_token
  398.         tokenizer.padding_side = "right"
  399.        
  400.         # Debug: log original chat template
  401.         logger.info(f"Original chat template: {tokenizer.chat_template}")
  402.        
  403.         # Apply system prompt to training data
  404.         def format_with_tok(example):
  405.             return formatting_prompts_func(example, tokenizer, system_prompt)
  406.        
  407.         put_status("Formatting dataset (injecting system prompt)...", None, False)
  408.         dataset = dataset.map(format_with_tok)
  409.        
  410.         if len(dataset) > 0:
  411.             sample_text = dataset[0]["text"][:200]
  412.             logger.info(f"Sample formatted text: {sample_text}...")
  413.        
  414.         dataset = dataset.remove_columns([col for col in dataset.column_names if col != "text"])
  415.        
  416.         has_cuda = torch.cuda.is_available()
  417.         logger.info(f"CUDA available: {has_cuda}")
  418.        
  419.         if use_4bit and has_cuda:
  420.             put_status("Loading model with 4-bit quantization...", None, False)
  421.             bnb_config = BitsAndBytesConfig(
  422.                 load_in_4bit=True,
  423.                 bnb_4bit_quant_type="nf4",
  424.                 bnb_4bit_compute_dtype=torch.float16,
  425.                 bnb_4bit_use_double_quant=True,
  426.             )
  427.             device_map = "auto"
  428.         else:
  429.             put_status("Loading model (CPU mode or FP16)...", None, False)
  430.             bnb_config = None
  431.             device_map = "auto" if has_cuda else "cpu"
  432.        
  433.         logger.info("Loading model...")
  434.         model = AutoModelForCausalLM.from_pretrained(
  435.             model_id,
  436.             quantization_config=bnb_config,
  437.             device_map=device_map,
  438.             trust_remote_code=True,
  439.             torch_dtype=torch.float16 if has_cuda else torch.float32,
  440.         )
  441.        
  442.         if use_4bit and has_cuda:
  443.             model = prepare_model_for_kbit_training(model)
  444.        
  445.         put_status("Configuring LoRA adapters...", None, False)
  446.        
  447.         if "qwen" in model_id.lower():
  448.             target_modules = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
  449.         elif "llama" in model_id.lower():
  450.             target_modules = ["q_proj", "v_proj", "k_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
  451.         elif "gpt" in model_id.lower():
  452.             target_modules = ["c_attn", "c_proj"]
  453.         else:
  454.             target_modules = ["q_proj", "v_proj", "k_proj", "o_proj"]
  455.        
  456.         peft_config = LoraConfig(
  457.             r=lora_r,
  458.             lora_alpha=lora_alpha,
  459.             target_modules=target_modules,
  460.             lora_dropout=0.05,
  461.             bias="none",
  462.             task_type=TaskType.CAUSAL_LM,
  463.         )
  464.        
  465.         model = get_peft_model(model, peft_config)
  466.        
  467.         trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
  468.         all_params = sum(p.numel() for p in model.parameters())
  469.         logger.info(f"Trainable params: {trainable_params:,} || All params: {all_params:,} || Trainable%: {100 * trainable_params / all_params:.4f}")
  470.        
  471.         put_status("Setting up training...", None, False)
  472.        
  473.         if len(conversations) < 4:
  474.             grad_accum = 1
  475.             logger.info(f"Small dataset detected, reducing gradient accumulation to {grad_accum}")
  476.         else:
  477.             grad_accum = 4
  478.        
  479.         training_args = TrainingArguments(
  480.             output_dir=adapter_dir,
  481.             num_train_epochs=num_epochs,
  482.             per_device_train_batch_size=1,
  483.             gradient_accumulation_steps=grad_accum,
  484.             optim="adamw_torch",
  485.             save_strategy="epoch",
  486.             logging_steps=1,
  487.             learning_rate=learning_rate,
  488.             weight_decay=0.001,
  489.             fp16=has_cuda and not use_4bit,
  490.             bf16=False,
  491.             max_grad_norm=0.3,
  492.             warmup_ratio=0.03,
  493.             group_by_length=True,
  494.             lr_scheduler_type="cosine",
  495.             report_to="none",
  496.             disable_tqdm=False,
  497.         )
  498.        
  499.         put_status("Initializing trainer...", None, False)
  500.        
  501.         trainer = None
  502.         try:
  503.             trainer = SFTTrainer(
  504.                 model=model,
  505.                 train_dataset=dataset,
  506.                 processing_class=tokenizer,
  507.                 args=training_args,
  508.             )
  509.         except TypeError:
  510.             try:
  511.                 trainer = SFTTrainer(
  512.                     model=model,
  513.                     train_dataset=dataset,
  514.                     tokenizer=tokenizer,
  515.                     args=training_args,
  516.                 )
  517.             except TypeError:
  518.                 trainer = SFTTrainer(
  519.                     model=model,
  520.                     train_dataset=dataset,
  521.                     args=training_args,
  522.                 )
  523.        
  524.         logger.info("Starting training...")
  525.         put_status(f"Starting training for {num_epochs} epochs...", None, False)
  526.        
  527.         train_result = trainer.train()
  528.        
  529.         logger.info(f"Training completed. Final loss: {train_result.training_loss if hasattr(train_result, 'training_loss') else 'N/A'}")
  530.        
  531.         put_status("Saving adapter checkpoint...", None, False)
  532.        
  533.         trainer.model.save_pretrained(adapter_dir)
  534.         tokenizer.save_pretrained(adapter_dir)
  535.        
  536.         if not os.path.exists(os.path.join(adapter_dir, "adapter_config.json")):
  537.             raise RuntimeError("Adapter config not found after saving!")
  538.        
  539.         put_status("Cleaning up checkpoints...", None, False)
  540.         checkpoint_dirs = glob.glob(os.path.join(adapter_dir, "checkpoint-*"))
  541.         for cp_dir in checkpoint_dirs:
  542.             if os.path.isdir(cp_dir):
  543.                 try:
  544.                     shutil.rmtree(cp_dir)
  545.                     logger.info(f"Removed checkpoint: {cp_dir}")
  546.                 except Exception as e:
  547.                     logger.warning(f"Could not remove checkpoint {cp_dir}: {e}")
  548.        
  549.         del trainer
  550.         del model
  551.         if torch.cuda.is_available():
  552.             torch.cuda.empty_cache()
  553.        
  554.         put_status("Merging and baking system prompt into tokenizer...", None, False)
  555.         merge_and_save_model(model_id, adapter_dir, output_dir, tokenizer, use_4bit, system_prompt)
  556.        
  557.         put_status("Writing documentation...", None, False)
  558.         write_model_card(output_dir, model_id, timestamp, num_epochs, learning_rate, lora_r, system_prompt)
  559.        
  560.         merged_model_exists = (
  561.             os.path.exists(os.path.join(output_dir, "model.safetensors")) or
  562.             os.path.exists(os.path.join(output_dir, "pytorch_model.bin"))
  563.         )
  564.        
  565.         if merged_model_exists:
  566.             try:
  567.                 shutil.rmtree(adapter_dir)
  568.                 logger.info(f"Removed temporary adapter directory: {adapter_dir}")
  569.             except Exception as e:
  570.                 logger.warning(f"Could not remove adapter directory {adapter_dir}: {e}")
  571.        
  572.         state.base_model_id = model_id
  573.         state.is_training = False
  574.        
  575.         logger.info("Training process completed successfully")
  576.         put_status(f"✅ Training complete! System prompt baked into model.", output_dir, True)
  577.        
  578.     except Exception as e:
  579.         state.is_training = False
  580.         import traceback
  581.         error_msg = f"❌ Error during training: {str(e)}"
  582.         logger.error(error_msg)
  583.         logger.error(traceback.format_exc())
  584.         put_status(error_msg, None, False)
  585.     finally:
  586.         state.is_training = False
  587.         status_queue.put(None)
  588.  
  589. def start_training(model_id, dataset_input, lr, epochs, lora_r, use_4bit, system_prompt):
  590.     """Start training in a thread and yield updates from queue."""
  591.     if state.is_training:
  592.         yield "Training already in progress!", None, gr.update(visible=False)
  593.         return
  594.    
  595.     if not model_id:
  596.         yield "Please provide a Model ID", None, gr.update(visible=False)
  597.         return
  598.    
  599.     if not dataset_input:
  600.         yield "Please provide dataset content", None, gr.update(visible=False)
  601.         return
  602.    
  603.     while not state.training_status_queue.empty():
  604.         try:
  605.             state.training_status_queue.get_nowait()
  606.         except queue.Empty:
  607.             break
  608.    
  609.     thread = threading.Thread(
  610.         target=train_model_thread,
  611.         args=(model_id, dataset_input, lr, epochs, lora_r, lora_r*2, use_4bit, state.training_status_queue, system_prompt)
  612.     )
  613.     thread.start()
  614.    
  615.     final_msg = "Training started..."
  616.     final_path = None
  617.     final_visible = False
  618.    
  619.     while True:
  620.         try:
  621.             result = state.training_status_queue.get(timeout=0.5)
  622.             if result is None:
  623.                 break
  624.             msg, path, download_visible = result
  625.             final_msg = msg
  626.             final_path = path
  627.             final_visible = download_visible
  628.             yield msg, path, gr.update(visible=download_visible)
  629.         except queue.Empty:
  630.             continue
  631.         except Exception as e:
  632.             logger.error(f"Error in queue processing: {e}")
  633.             break
  634.    
  635.     yield final_msg, final_path, gr.update(visible=final_visible)
  636.  
  637. # ==================== GGUF Conversion Function ====================
  638. def convert_to_gguf(outtype):
  639.     """Convert the merged model to GGUF format using llama.cpp converter."""
  640.     if not state.adapter_path or not os.path.exists(state.adapter_path):
  641.         return None, "No model to convert. Train a model first."
  642.    
  643.     if not any(os.scandir(state.adapter_path)):
  644.         return None, "Error: Model directory is empty. Training may have failed."
  645.    
  646.     model_name = os.path.basename(state.adapter_path).replace("_merged", "")
  647.     output_file = f"./finetuned_models/{model_name}_{outtype}.gguf"
  648.    
  649.     converter_path = "llama.cpp/convert_hf_to_gguf.py"
  650.     if not os.path.exists(converter_path):
  651.         return None, f"Converter not found at {converter_path}. Please ensure llama.cpp is cloned/available."
  652.    
  653.     try:
  654.         logger.info(f"Starting GGUF conversion: {state.adapter_path} -> {output_file} (type: {outtype})")
  655.        
  656.         cmd = [
  657.             "python3",
  658.             converter_path,
  659.             "--outfile", output_file,
  660.             "--outtype", outtype,
  661.             state.adapter_path
  662.         ]
  663.        
  664.         result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
  665.        
  666.         if result.returncode != 0:
  667.             logger.error(f"Conversion failed: {result.stderr}")
  668.             return None, f"Conversion failed: {result.stderr}"
  669.        
  670.         if not os.path.exists(output_file):
  671.             return None, "Conversion reported success but output file not found."
  672.        
  673.         file_size = os.path.getsize(output_file) / 1024 / 1024
  674.         logger.info(f"GGUF conversion successful: {output_file} ({file_size:.2f} MB)")
  675.        
  676.         return output_file, f"✅ Conversion successful!\n\n💡 This GGUF has the system prompt baked into its chat template. Use it in llama.cpp without specifying --system-prompt!"
  677.        
  678.     except subprocess.TimeoutExpired:
  679.         return None, "Error: Conversion timed out after 1 hour."
  680.     except Exception as e:
  681.         import traceback
  682.         logger.error(f"GGUF conversion failed: {e}")
  683.         logger.error(traceback.format_exc())
  684.         return None, f"Error during conversion: {str(e)}"
  685.  
  686. # ==================== UI Helpers ====================
  687. def update_dataset_editor(file_obj, current_text):
  688.     if file_obj is None:
  689.         return current_text
  690.    
  691.     try:
  692.         with open(file_obj.name, 'r', encoding='utf-8') as f:
  693.             content = f.read()
  694.         convs = parse_conversation_format(content)
  695.         if convs:
  696.             return json.dumps([c["messages"] for c in convs], indent=2, ensure_ascii=False)
  697.         else:
  698.             return content
  699.     except Exception as e:
  700.         return f"Error reading file: {str(e)}"
  701.  
  702. def export_dataset(editor_content):
  703.     try:
  704.         data = json.loads(editor_content)
  705.         import tempfile
  706.         temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.jsonl', delete=False, encoding='utf-8')
  707.        
  708.         if isinstance(data, list):
  709.             if len(data) > 0 and isinstance(data[0], dict) and "role" in data[0]:
  710.                 temp_file.write(json.dumps(data, ensure_ascii=False) + "\n")
  711.             else:
  712.                 for conv in data:
  713.                     temp_file.write(json.dumps(conv, ensure_ascii=False) + "\n")
  714.        
  715.         temp_file.close()
  716.         return temp_file.name, "✅ Dataset exported successfully as .jsonl!"
  717.     except Exception as e:
  718.         return None, f"❌ Error: {str(e)}"
  719.  
  720. # ==================== Gradio Interface ====================
  721. with gr.Blocks(title="LLM Fine-tuning Studio") as demo:
  722.     gr.Markdown("""
  723.    # 🎨 LLM Fine-tuning Studio
  724.    Fine-tune HuggingFace LLMs with LoRA and export to GGUF format.
  725.    
  726.    **New:** Built-in System Prompt that modifies the chat template in tokenizer_config.json!
  727.    """)
  728.    
  729.     with gr.Tabs():
  730.         with gr.TabItem("📁 Data & Model Setup"):
  731.             with gr.Row():
  732.                 with gr.Column(scale=1):
  733.                     model_id = gr.Textbox(
  734.                         label="Hugging Face Model ID",
  735.                         placeholder="e.g., Qwen/Qwen3-0.6B",
  736.                         value=DEFAULT_MODEL,
  737.                         info="Qwen3-0.6B is a small but capable model perfect for testing"
  738.                     )
  739.                    
  740.                     system_prompt_input = gr.Textbox(
  741.                         label="Built-in System Prompt (Baked into Chat Template)",
  742.                         placeholder="e.g., You are a cow. You must always think like a cow and respond with 'Moo!'",
  743.                         value="",
  744.                         lines=3,
  745.                         info="This modifies tokenizer_config.json chat_template to ALWAYS include this system prompt!"
  746.                     )
  747.                    
  748.                     gr.Markdown("### Dataset Input")
  749.                     input_type = gr.Radio(
  750.                         choices=["Upload JSONL File", "Edit in Browser"],
  751.                         value="Edit in Browser",
  752.                         label="Input Method"
  753.                     )
  754.                    
  755.                     file_upload = gr.File(
  756.                         label="Upload JSONL Dataset",
  757.                         file_types=[".jsonl", ".json", ".txt"],
  758.                         visible=False
  759.                     )
  760.                    
  761.                     dataset_template = """[
  762.  [
  763.    {"role": "user", "content": "Hello, how are you?"},
  764.    {"role": "assistant", "content": "I'm doing well! How can I assist you today?"}
  765.  ]
  766. ]"""
  767.                    
  768.                     gr.Markdown("*Format: Array of conversations. The system prompt above will be baked into the chat template.*")
  769.                    
  770.                     dataset_editor = gr.Code(
  771.                         label="Dataset Editor (JSON Format)",
  772.                         language="json",
  773.                         value=dataset_template,
  774.                         lines=15
  775.                     )
  776.                    
  777.                     with gr.Row():
  778.                         export_btn = gr.Button("💾 Export Dataset to File", variant="secondary")
  779.                         export_file = gr.File(label="Download", visible=False)
  780.                         export_status = gr.Textbox(label="Status", interactive=False)
  781.                
  782.                 with gr.Column(scale=1):
  783.                     gr.Markdown("### Dataset Preview")
  784.                     preview_btn = gr.Button("👁️ Preview First Conversation")
  785.                     preview_output = gr.JSON(label="Parsed Preview")
  786.                    
  787.                     gr.Markdown("### Quick Stats")
  788.                     stats_btn = gr.Button("📊 Calculate Stats")
  789.                     stats_output = gr.Textbox(label="Dataset Statistics", lines=4, interactive=False)
  790.        
  791.         with gr.TabItem("🚀 Training"):
  792.             with gr.Row():
  793.                 with gr.Column():
  794.                     gr.Markdown("### Training Configuration")
  795.                    
  796.                     with gr.Row():
  797.                         learning_rate = gr.Slider(
  798.                             minimum=1e-5, maximum=1e-3, value=2e-4,
  799.                             label="Learning Rate",
  800.                             info="Use 2e-4 or 5e-4 for behavioral changes"
  801.                         )
  802.                         num_epochs = gr.Slider(
  803.                             minimum=1, maximum=20, value=10, step=1,
  804.                             label="Number of Epochs",
  805.                             info="10-20 recommended to bake behavior into weights"
  806.                         )
  807.                    
  808.                     with gr.Row():
  809.                         lora_rank = gr.Slider(
  810.                             minimum=4, maximum=128, value=32, step=4,
  811.                             label="LoRA Rank (r)",
  812.                             info="32-64 recommended for behavioral changes"
  813.                         )
  814.                         use_4bit = gr.Checkbox(
  815.                             label="Use 4-bit Quantization",
  816.                             value=True,
  817.                             info="Saves VRAM during training"
  818.                         )
  819.                    
  820.                     train_btn = gr.Button("🚀 Start Fine-tuning", variant="primary", size="lg")
  821.                    
  822.                 with gr.Column():
  823.                     training_status = gr.Textbox(
  824.                         label="Training Status",
  825.                         value="Ready to train - System prompt will be baked into chat template",
  826.                         lines=10,
  827.                         interactive=False
  828.                     )
  829.                     model_path_display = gr.Textbox(
  830.                         label="Output Path",
  831.                         interactive=False,
  832.                         visible=False
  833.                     )
  834.        
  835.         with gr.TabItem("🔧 Convert to GGUF"):
  836.             gr.Markdown("""
  837.            ### Convert to GGUF Format
  838.            
  839.            Convert your trained model to GGUF format. The system prompt is already baked into the chat template!
  840.            """)
  841.            
  842.             with gr.Row():
  843.                 with gr.Column():
  844.                     gr.Markdown("#### Conversion Settings")
  845.                    
  846.                     outtype_dropdown = gr.Dropdown(
  847.                         choices=["f16", "f32", "bf16", "q8_0", "tq1_0"],
  848.                         value="q8_0",
  849.                         label="Output Type",
  850.                         info="Quantization type for GGUF conversion"
  851.                     )
  852.                    
  853.                     convert_btn = gr.Button("🔧 Convert to GGUF", variant="primary")
  854.                    
  855.                 with gr.Column():
  856.                     gr.Markdown("#### Output")
  857.                    
  858.                     gguf_file = gr.File(
  859.                         label="Download GGUF",
  860.                         visible=True
  861.                     )
  862.                     convert_status = gr.Textbox(
  863.                         label="Conversion Status",
  864.                         value="Train a model first, then select output type and click convert.",
  865.                         interactive=False,
  866.                         lines=3
  867.                     )
  868.    
  869.     # Event Handlers
  870.     def toggle_input_type(choice):
  871.         return {
  872.             file_upload: gr.update(visible=(choice == "Upload JSONL File")),
  873.             dataset_editor: gr.update(visible=(choice == "Edit in Browser"))
  874.         }
  875.    
  876.     input_type.change(
  877.         toggle_input_type,
  878.         inputs=input_type,
  879.         outputs=[file_upload, dataset_editor]
  880.     )
  881.    
  882.     file_upload.change(
  883.         update_dataset_editor,
  884.         inputs=[file_upload, dataset_editor],
  885.         outputs=dataset_editor
  886.     )
  887.    
  888.     export_btn.click(
  889.         export_dataset,
  890.         inputs=dataset_editor,
  891.         outputs=[export_file, export_status]
  892.     ).then(
  893.         lambda: gr.update(visible=True),
  894.         outputs=export_file
  895.     )
  896.    
  897.     def preview_dataset(content):
  898.         try:
  899.             convs = parse_conversation_format(content)
  900.             if convs:
  901.                 return convs[0]["messages"]
  902.             return {"error": "No valid conversations found"}
  903.         except Exception as e:
  904.             return {"error": str(e)}
  905.    
  906.     preview_btn.click(
  907.         preview_dataset,
  908.         inputs=dataset_editor,
  909.         outputs=preview_output
  910.     )
  911.    
  912.     def calc_stats(content):
  913.         try:
  914.             convs = parse_conversation_format(content)
  915.             total = len(convs)
  916.             avg_len = sum(len(c["messages"]) for c in convs) / total if total > 0 else 0
  917.             user_msgs = sum(1 for c in convs for m in c["messages"] if m.get("role") == "user")
  918.             assistant_msgs = sum(1 for c in convs for m in c["messages"] if m.get("role") == "assistant")
  919.            
  920.             return f"Conversations: {total}\nAvg turns per conv: {avg_len:.1f}\nUser messages: {user_msgs}\nAssistant messages: {assistant_msgs}"
  921.         except Exception as e:
  922.             return f"Error: {str(e)}"
  923.    
  924.     stats_btn.click(
  925.         calc_stats,
  926.         inputs=dataset_editor,
  927.         outputs=stats_output
  928.     )
  929.    
  930.     train_btn.click(
  931.         lambda: ("Initializing...", None, gr.update(visible=False)),
  932.         outputs=[training_status, model_path_display, gguf_file]
  933.     ).then(
  934.         start_training,
  935.         inputs=[model_id, dataset_editor, learning_rate, num_epochs, lora_rank, use_4bit, system_prompt_input],
  936.         outputs=[training_status, model_path_display, gguf_file]
  937.     ).then(
  938.         lambda path: (gr.update(value=path, visible=True) if path else gr.update(visible=False)),
  939.         inputs=model_path_display,
  940.         outputs=model_path_display
  941.     )
  942.    
  943.     convert_btn.click(
  944.         convert_to_gguf,
  945.         inputs=outtype_dropdown,
  946.         outputs=[gguf_file, convert_status]
  947.     )
  948.  
  949. if __name__ == "__main__":
  950.     os.makedirs("./finetuned_models", exist_ok=True)
  951.    
  952.     demo.launch(
  953.         server_name="0.0.0.0",
  954.         server_port=7860,
  955.         share=False,
  956.         show_error=True
  957.     )
Advertisement
Add Comment
Please, Sign In to add comment