| """ |
| QLoRA fine-tuning for medical QA models. |
| """ |
| import os |
| import sys |
| from pathlib import Path |
| sys.path.insert(0, str(Path(__file__).parent.parent.parent)) |
|
|
| import torch |
| from typing import List, Dict, Optional |
| from dataclasses import dataclass, field |
| from transformers import ( |
| AutoModelForCausalLM, |
| AutoTokenizer, |
| TrainingArguments, |
| Trainer, |
| DataCollatorForLanguageModeling, |
| BitsAndBytesConfig |
| ) |
| from peft import ( |
| LoraConfig, |
| get_peft_model, |
| prepare_model_for_kbit_training, |
| TaskType |
| ) |
| from datasets import Dataset |
| import json |
|
|
| @dataclass |
| class QLoRAConfig: |
| """Configuration for QLoRA fine-tuning.""" |
| |
| base_model: str = "TinyLlama/TinyLlama-1.1B-Chat-v1.0" |
| output_dir: str = "models/fine_tuned" |
| |
| |
| lora_r: int = 16 |
| lora_alpha: int = 32 |
| lora_dropout: float = 0.05 |
| target_modules: List[str] = field(default_factory=lambda: [ |
| "q_proj", "k_proj", "v_proj", "o_proj", |
| "gate_proj", "up_proj", "down_proj" |
| ]) |
| |
| |
| num_epochs: int = 3 |
| max_steps: int = -1 |
| batch_size: int = 4 |
| gradient_accumulation_steps: int = 4 |
| learning_rate: float = 2e-4 |
| max_seq_length: int = 512 |
| warmup_ratio: float = 0.03 |
| |
| |
| load_in_4bit: bool = True |
| bnb_4bit_compute_dtype: str = "float16" |
| bnb_4bit_quant_type: str = "nf4" |
| use_double_quant: bool = True |
|
|
| class MedicalQADatasetBuilder: |
| """Build training dataset for medical QA fine-tuning.""" |
| |
| INSTRUCTION_TEMPLATE = """Below is a medical question. Provide an accurate, helpful answer based on medical knowledge. |
| |
| ### Question: |
| {question} |
| |
| ### Answer: |
| {answer}""" |
|
|
| def __init__(self, tokenizer, max_length: int = 512): |
| self.tokenizer = tokenizer |
| self.max_length = max_length |
| |
| def format_example(self, question: str, answer: str) -> str: |
| """Format a single QA pair for training.""" |
| return self.INSTRUCTION_TEMPLATE.format( |
| question=question, |
| answer=answer |
| ) |
| |
| def prepare_dataset(self, qa_pairs: List[Dict]) -> Dataset: |
| """Prepare training dataset from QA pairs.""" |
| formatted_texts = [] |
| |
| for qa in qa_pairs: |
| text = self.format_example( |
| question=qa.get("question", ""), |
| answer=qa.get("answer", "") |
| ) |
| formatted_texts.append({"text": text}) |
| |
| dataset = Dataset.from_list(formatted_texts) |
| |
| |
| def tokenize(examples): |
| return self.tokenizer( |
| examples["text"], |
| truncation=True, |
| max_length=self.max_length, |
| padding="max_length" |
| ) |
| |
| tokenized_dataset = dataset.map( |
| tokenize, |
| batched=True, |
| remove_columns=["text"] |
| ) |
| |
| return tokenized_dataset |
|
|
| class QLoRATrainer: |
| """Fine-tune medical LLM using QLoRA.""" |
| |
| def __init__(self, config: QLoRAConfig): |
| self.config = config |
| self.model = None |
| self.tokenizer = None |
| self.peft_model = None |
| |
| def setup_model(self): |
| """Load and prepare model for QLoRA training.""" |
| print(f"π Loading base model: {self.config.base_model}") |
| |
| |
| if self.config.load_in_4bit: |
| bnb_config = BitsAndBytesConfig( |
| load_in_4bit=True, |
| bnb_4bit_compute_dtype=getattr(torch, self.config.bnb_4bit_compute_dtype), |
| bnb_4bit_quant_type=self.config.bnb_4bit_quant_type, |
| bnb_4bit_use_double_quant=self.config.use_double_quant |
| ) |
| else: |
| bnb_config = None |
| |
| |
| self.tokenizer = AutoTokenizer.from_pretrained( |
| self.config.base_model, |
| trust_remote_code=True |
| ) |
| if self.tokenizer.pad_token is None: |
| self.tokenizer.pad_token = self.tokenizer.eos_token |
| |
| |
| self.model = AutoModelForCausalLM.from_pretrained( |
| self.config.base_model, |
| quantization_config=bnb_config, |
| device_map="auto", |
| trust_remote_code=True |
| ) |
| |
| |
| if self.config.load_in_4bit: |
| self.model = prepare_model_for_kbit_training(self.model) |
| |
| |
| lora_config = LoraConfig( |
| r=self.config.lora_r, |
| lora_alpha=self.config.lora_alpha, |
| lora_dropout=self.config.lora_dropout, |
| target_modules=self.config.target_modules, |
| bias="none", |
| task_type=TaskType.CAUSAL_LM |
| ) |
| |
| |
| self.peft_model = get_peft_model(self.model, lora_config) |
| |
| trainable_params = sum(p.numel() for p in self.peft_model.parameters() if p.requires_grad) |
| total_params = sum(p.numel() for p in self.peft_model.parameters()) |
| print(f"β
Model loaded. Trainable: {trainable_params:,} / {total_params:,} ({100*trainable_params/total_params:.2f}%)") |
| |
| return self.peft_model |
| |
| def train(self, train_dataset: Dataset, eval_dataset: Optional[Dataset] = None): |
| """Run QLoRA fine-tuning.""" |
| if self.peft_model is None: |
| self.setup_model() |
| |
| |
| training_args = TrainingArguments( |
| output_dir=self.config.output_dir, |
| num_train_epochs=self.config.num_epochs, |
| max_steps=self.config.max_steps, |
| per_device_train_batch_size=self.config.batch_size, |
| gradient_accumulation_steps=self.config.gradient_accumulation_steps, |
| learning_rate=self.config.learning_rate, |
| warmup_ratio=self.config.warmup_ratio, |
| logging_steps=10, |
| save_strategy="epoch", |
| eval_strategy="epoch" if eval_dataset else "no", |
| fp16=True, |
| optim="paged_adamw_8bit", |
| report_to="none", |
| remove_unused_columns=False |
| ) |
| |
| |
| data_collator = DataCollatorForLanguageModeling( |
| tokenizer=self.tokenizer, |
| mlm=False |
| ) |
| |
| |
| trainer = Trainer( |
| model=self.peft_model, |
| args=training_args, |
| train_dataset=train_dataset, |
| eval_dataset=eval_dataset, |
| data_collator=data_collator |
| ) |
| |
| print("π Starting QLoRA fine-tuning...") |
| trainer.train() |
| |
| |
| self.save_model() |
| |
| return trainer |
| |
| def save_model(self, path: Optional[str] = None): |
| """Save fine-tuned model.""" |
| save_path = path or self.config.output_dir |
| Path(save_path).mkdir(parents=True, exist_ok=True) |
| |
| self.peft_model.save_pretrained(save_path) |
| self.tokenizer.save_pretrained(save_path) |
| |
| |
| config_path = Path(save_path) / "qlora_config.json" |
| with open(config_path, "w") as f: |
| json.dump({ |
| "base_model": self.config.base_model, |
| "lora_r": self.config.lora_r, |
| "lora_alpha": self.config.lora_alpha |
| }, f, indent=2) |
| |
| print(f"β
Model saved to {save_path}") |
| |
| def load_fine_tuned(self, path: str): |
| """Load a fine-tuned model.""" |
| from peft import PeftModel |
| |
| |
| config_path = Path(path) / "qlora_config.json" |
| with open(config_path) as f: |
| saved_config = json.load(f) |
| |
| |
| self.tokenizer = AutoTokenizer.from_pretrained(path) |
| base_model = AutoModelForCausalLM.from_pretrained( |
| saved_config["base_model"], |
| device_map="auto", |
| trust_remote_code=True |
| ) |
| |
| |
| self.peft_model = PeftModel.from_pretrained(base_model, path) |
| |
| print(f"β
Loaded fine-tuned model from {path}") |
| return self.peft_model |
|
|
|
|
| def main(): |
| """Example fine-tuning script.""" |
| print("π₯ Medical QA Fine-tuning with QLoRA\n") |
| print("=" * 50) |
| |
| |
| sample_qa = [ |
| { |
| "question": "What are the symptoms of diabetes?", |
| "answer": "Common symptoms of diabetes include increased thirst (polydipsia), frequent urination (polyuria), unexplained weight loss, fatigue, blurred vision, slow-healing wounds, and frequent infections. Type 1 diabetes symptoms often appear suddenly, while Type 2 diabetes symptoms may develop gradually." |
| }, |
| { |
| "question": "How is high blood pressure treated?", |
| "answer": "High blood pressure is typically treated through lifestyle modifications and medications. Lifestyle changes include reducing sodium intake, regular exercise, maintaining a healthy weight, limiting alcohol, and managing stress. Medications may include ACE inhibitors, ARBs, calcium channel blockers, diuretics, or beta-blockers." |
| } |
| ] |
| |
| |
| config = QLoRAConfig( |
| base_model="TinyLlama/TinyLlama-1.1B-Chat-v1.0", |
| output_dir="models/medical_qa_lora", |
| num_epochs=1, |
| max_steps=5, |
| batch_size=2 |
| ) |
| |
| trainer = QLoRATrainer(config) |
| trainer.setup_model() |
| |
| |
| dataset_builder = MedicalQADatasetBuilder( |
| trainer.tokenizer, |
| max_length=config.max_seq_length |
| ) |
| train_dataset = dataset_builder.prepare_dataset(sample_qa) |
| |
| print(f"\nπ Training dataset: {len(train_dataset)} examples") |
| |
| |
| |
| |
| print("\nβ
Fine-tuning setup complete!") |
| print("To train, uncomment trainer.train(train_dataset)") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|