Saltar al contenido

Fine-tuning de modelos de lenguaje con datos específicos del dominio

Ajusta modelos de lenguaje a tu dominio con LoRA y QLoRA: preparación de datasets, estrategias de entrenamiento y evaluación sin grandes presupuestos.

6 min de lectura
Curva de pérdida de entrenamiento que muestra el fine-tuning de un modelo de lenguaje con datos específicos del dominio, checkpoints de validación y early stopping

Los modelos de lenguaje de propósito general saben un poco de todo. Pueden escribir poesía, explicar física cuántica y generar código. Pero pregúntales sobre las APIs internas de tu empresa, la terminología específica de tu industria o las convenciones de código de tu organización, y alucinan con confianza. El ajuste fino transforma a un generalista en un especialista: un modelo que entiende el lenguaje, los patrones y las restricciones de tu dominio.

La barrera para hacer ajuste fino se ha derrumbado. Métodos eficientes en parámetros como LoRA permiten ajustar un modelo de 7B parámetros en una sola GPU en horas. No necesitas un equipo de ingenieros de ML ni un cluster de A100s. Necesitas buenos datos, objetivos claros y la disciplina de evaluar si tu modelo ajustado realmente mejora al modelo base para tu caso de uso específico.

Cuándo tiene sentido el ajuste fino

El ajuste fino no siempre es la respuesta. Antes de invertir en entrenamiento, entiende cuándo es la herramienta adecuada.

markdownmarkdown
## Decision framework
 
### Use fine-tuning when:
- You need the model to adopt a specific style or format
  (medical reports, legal briefs, code in your framework)
- Domain-specific terminology that the base model gets wrong
- You have 100+ high-quality examples of desired behavior
- Prompt engineering alone can't achieve consistent quality
- Latency matters and you can't afford long prompts with 
  many examples
 
### Use RAG (retrieval) instead when:
- The knowledge changes frequently (product catalog, docs)
- You need citations and verifiable sources
- The base model understands the domain but lacks specifics
- You have structured data that's easy to retrieve
 
### Use prompt engineering instead when:
- You have fewer than 50 examples
- The task is well-defined and consistent
- Few-shot examples in the prompt achieve good results
- You need flexibility to change behavior without retraining

Preparación de los datos de entrenamiento

La calidad de tu dataset de ajuste fino determina la calidad de tu modelo. Si entra basura, sale basura: aquí aplica más que en cualquier otra parte del software.

pypython
# Dataset preparation for instruction fine-tuning
import json
from typing import TypedDict
 
class TrainingExample(TypedDict):
    instruction: str
    input: str
    output: str
 
# ❌ Low-quality training data
bad_examples = [
    {
        "instruction": "Write code",
        "input": "make a function",
        "output": "def foo(): pass"  # Too vague, teaches nothing
    },
    {
        "instruction": "Fix the bug",
        "input": "it doesn't work",
        "output": "try restarting"  # Not actionable
    },
]
 
# ✅ High-quality training data
good_examples: list[TrainingExample] = [
    {
        "instruction": (
            "Write a TypeScript function that validates "
            "an email address using our company's domain rules."
        ),
        "input": (
            "Requirements: Must end with @company.com or "
            "@subsidiary.com. Must not contain plus addressing. "
            "Must be lowercase."
        ),
        "output": (
            "function validateCompanyEmail(email: string): "
            "{ valid: boolean; error?: string } {\n"
            "  const normalized = email.toLowerCase().trim();\n"
            "  \n"
            "  if (normalized !== email) {\n"
            "    return { valid: false, error: "
            "'Email must be lowercase' };\n"
            "  }\n"
            "  \n"
            "  if (normalized.includes('+')) {\n"
            "    return { valid: false, error: "
            "'Plus addressing not allowed' };\n"
            "  }\n"
            "  \n"
            "  const allowedDomains = "
            "['@company.com', '@subsidiary.com'];\n"
            "  const hasValidDomain = allowedDomains.some(\n"
            "    domain => normalized.endsWith(domain)\n"
            "  );\n"
            "  \n"
            "  if (!hasValidDomain) {\n"
            "    return { valid: false, error: "
            "'Must use company email domain' };\n"
            "  }\n"
            "  \n"
            "  return { valid: true };\n"
            "}"
        ),
    },
]
 
 
def prepare_dataset(
    examples: list[TrainingExample],
    output_path: str
) -> None:
    """Format examples for fine-tuning frameworks."""
    formatted = []
    for ex in examples:
        formatted.append({
            "messages": [
                {
                    "role": "system",
                    "content": (
                        "You are a senior engineer at Company. "
                        "Follow our coding standards and conventions."
                    ),
                },
                {
                    "role": "user",
                    "content": f"{ex['instruction']}\n\n{ex['input']}",
                },
                {
                    "role": "assistant",
                    "content": ex["output"],
                },
            ]
        })
 
    with open(output_path, "w") as f:
        for item in formatted:
            f.write(json.dumps(item) + "\n")

LoRA: ajuste fino sin la factura de GPU

Low-Rank Adaptation (LoRA) congela los pesos originales del modelo y entrena pequeñas matrices de adaptación. Esto reduce los parámetros entrenables en más del 99% mientras logra una calidad comparable al ajuste fino completo.

pypython
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model, TaskType
from datasets import load_dataset
from trl import SFTTrainer, SFTConfig
 
# Load base model
model_name = "meta-llama/Llama-3.1-8B-Instruct"
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype="auto",
    device_map="auto",
)
tokenizer = AutoTokenizer.from_pretrained(model_name)
 
# LoRA configuration
# r: rank of the adaptation matrices (lower = fewer params)
# alpha: scaling factor (typically 2x rank)
# target_modules: which layers to adapt
 
# ❌ Too aggressive: high rank, all modules
bad_config = LoraConfig(
    r=128,           # Way too many parameters
    lora_alpha=256,
    target_modules="all-linear",  # Unnecessary
)
 
# ✅ Balanced: enough capacity for domain adaptation
lora_config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
    bias="none",
)
 
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: 13,631,488 || all params: 8,043,167,744
# trainable%: 0.1695

Bucle de entrenamiento e hiperparámetros

La configuración de entrenamiento importa más de lo que la mayoría cree. La tasa de aprendizaje, el tamaño del batch y la cantidad de épocas pueden marcar la diferencia entre un modelo útil y uno catastróficamente sobreajustado.

pypython
# Training configuration
training_config = SFTConfig(
    output_dir="./checkpoints",
    
    # Learning rate: too high = catastrophic forgetting
    # too low = you're just wasting compute
    learning_rate=2e-4,
    
    # Warmup prevents early instability
    warmup_steps=100,
    lr_scheduler_type="cosine",
    
    # Epochs: 1-3 for most datasets
    # More epochs = more overfitting risk
    num_train_epochs=2,
    
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,  # Effective batch size: 16
    
    # Evaluation during training to catch overfitting
    eval_strategy="steps",
    eval_steps=50,
    save_strategy="steps",
    save_steps=50,
    
    # Mixed precision for memory efficiency
    bf16=True,
    
    logging_steps=10,
    max_seq_length=2048,
)
 
# Load dataset
dataset = load_dataset("json", data_files={
    "train": "data/train.jsonl",
    "eval": "data/eval.jsonl",
})
 
# Initialize trainer
trainer = SFTTrainer(
    model=model,
    args=training_config,
    train_dataset=dataset["train"],
    eval_dataset=dataset["eval"],
    processing_class=tokenizer,
)
 
# Train
trainer.train()
 
# Save the adapter (not the full model)
trainer.save_model("./final-adapter")
# Adapter is ~50MB vs 16GB for the full model

Evaluación: ¿el modelo ajustado realmente ayuda?

El paso más importante es la evaluación rigurosa. Un modelo que obtiene buena loss de entrenamiento pero produce peores salidas que el modelo base con buen prompting es una pérdida de tiempo.

pypython
# Evaluation framework
from dataclasses import dataclass
 
@dataclass
class EvalResult:
    example_id: str
    base_output: str
    finetuned_output: str
    reference_output: str
    base_score: float
    finetuned_score: float
 
def evaluate_model(
    base_model,
    finetuned_model,
    eval_examples: list[dict],
    scorer,
) -> list[EvalResult]:
    """Compare base vs fine-tuned on held-out examples."""
    results = []
 
    for example in eval_examples:
        prompt = example["instruction"] + "\n" + example["input"]
 
        base_output = generate(base_model, prompt)
        ft_output = generate(finetuned_model, prompt)
 
        base_score = scorer.score(
            base_output, example["output"]
        )
        ft_score = scorer.score(
            ft_output, example["output"]
        )
 
        results.append(EvalResult(
            example_id=example["id"],
            base_output=base_output,
            finetuned_output=ft_output,
            reference_output=example["output"],
            base_score=base_score,
            finetuned_score=ft_score,
        ))
 
    # Summary statistics
    base_avg = sum(r.base_score for r in results) / len(results)
    ft_avg = sum(r.finetuned_score for r in results) / len(results)
    
    print(f"Base model average: {base_avg:.3f}")
    print(f"Fine-tuned average: {ft_avg:.3f}")
    print(f"Improvement: {(ft_avg - base_avg) / base_avg * 100:.1f}%")
 
    # Check for regressions: cases where fine-tuned is worse
    regressions = [
        r for r in results 
        if r.finetuned_score < r.base_score
    ]
    print(f"Regressions: {len(regressions)}/{len(results)}")
 
    return results

Errores comunes

pypython
# ❌ Pitfall 1: Overfitting on small datasets
# Symptom: training loss drops to near zero, 
# eval loss increases
# Fix: fewer epochs, more dropout, more data
 
# ❌ Pitfall 2: Catastrophic forgetting
# Symptom: domain task improves but general 
# capability degrades
# Fix: lower learning rate, mix in general data (5-10%)
 
# ❌ Pitfall 3: Poor data quality
# Symptom: model learns your mistakes and inconsistencies
# Fix: have domain experts review training examples
# Quality check: would you want a junior dev to 
# learn from this example?
 
# ✅ Practical data quality checklist:
# - Every example has a clear, complete answer
# - No contradictions between examples
# - Consistent formatting and style
# - At least 100 examples for narrow tasks
# - At least 500 for broader domain adaptation
# - 10-20% held out for evaluation

Conclusiones clave

El ajuste fino es la elección correcta cuando necesitas un estilo, formato o terminología de dominio consistentes que el prompting solo no logra de forma confiable, pero si tu conocimiento cambia frecuentemente o necesitas citas, la generación aumentada con recuperación (RAG) suele ser una mejor opción. LoRA hace el ajuste fino accesible en hardware de consumo al entrenar pequeñas matrices adaptadoras (0,17% de los parámetros totales) mientras logra resultados comparables al ajuste fino completo: empieza con rank 16, apunta a las capas de proyección de atención y feed-forward, y usa un schedule de tasa de aprendizaje coseno comenzando en 2e-4. La calidad de los datos importa más que la cantidad: 200 ejemplos curados por expertos, donde cada uno demuestra exactamente el comportamiento que buscas, superan a 10.000 ejemplos ruidosos extraídos de la web, y cada ejemplo de entrenamiento debería pasar la prueba de "¿querría que un desarrollador junior aprendiera de esto?". Evalúa siempre contra el modelo base en ejemplos reservados antes de desplegar, porque un modelo ajustado que tiene buena loss de entrenamiento pero produce peores salidas que el modelo base con buen prompting tiene valor negativo: revisa regresiones y olvido catastrófico, no solo la mejora promedio.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX