AV
All writing

Language Model Distillation

18 March 2026 · 6 min read

Language model distillation and how to do it on your own. Train your own student model.

Language Model Distillation

Knowledge distillation is a technique for training smaller neural networks to perform like larger ones. The basic idea is simple: train a small "student" model to copy the behavior of a large "teacher" model. This lets you compress years of training and billions of parameters into something you can actually deploy. But making this work well requires understanding probability matching, intermediate representations, and training dynamics.

How Distillation Works

Hinton and his colleagues created knowledge distillation after noticing something useful: when a trained model makes predictions, it outputs a probability distribution that contains more information than just the final answer. Say a teacher model assigns 80% probability to the correct class, 15% to a similar class, and 5% to an unrelated class. This distribution tells you about relationships between classes that a simple correct/incorrect label throws away. The student learns not just what to predict, but how the teacher thinks about alternatives.

The distillation loss function combines two parts. First, it measures how well the student matches the teacher's soft predictions using KL divergence. Second, it makes sure the student still learns from the actual labels using cross-entropy loss. You control the balance between these with a parameter called alpha.

import torch
import torch.nn as nn
import torch.nn.functional as F

class DistillationLoss(nn.Module):
    def __init__(self, temperature=3.0, alpha=0.7):
        """
        Args:
            temperature: Controls softness of probability distributions
            alpha: Weight balancing distillation vs hard label loss
        """
        super().__init__()
        self.temperature = temperature
        self.alpha = alpha
        self.kl_div = nn.KLDivLoss(reduction='batchmean')
        self.ce_loss = nn.CrossEntropyLoss()
    
    def forward(self, student_logits, teacher_logits, labels):
        # Soften distributions with temperature scaling
        student_soft = F.log_softmax(student_logits / self.temperature, dim=1)
        teacher_soft = F.softmax(teacher_logits / self.temperature, dim=1)
        
        # Distillation loss (KL divergence between soft distributions)
        distillation_loss = self.kl_div(student_soft, teacher_soft) * (self.temperature ** 2)
        
        # Standard cross-entropy with hard labels
        student_loss = self.ce_loss(student_logits, labels)
        
        # Combined objective
        return self.alpha * distillation_loss + (1 - self.alpha) * student_loss

Temperature is important here. Higher temperature makes the probability distribution "softer," revealing more about class relationships. At temperature 1, you get normal softmax. As temperature goes up, the distribution gets more uniform. The best temperature is usually between 2 and 5. The temperature squared term in the loss keeps gradients consistent across different temperature settings.

Dealing with Size Differences

The gap between teacher and student size fundamentally limits how well distillation works. If the student is too small, it can't capture what the teacher knows. If it's too large, you're not getting much compression. You need to find the point where the student is small enough to be useful but large enough to learn the important patterns.

Different approaches handle this gap in different ways. Basic distillation just matches output distributions. Intermediate distillation also aligns hidden layer representations. This works better for transformers where intermediate attention patterns encode important linguistic knowledge. Patient Knowledge Distillation goes further by matching relationships between layers, not just individual layers.

class IntermediateDistillation(nn.Module):
    def __init__(self, student_dim, teacher_dim, num_student_layers, num_teacher_layers):
        super().__init__()
        self.num_student_layers = num_student_layers
        self.num_teacher_layers = num_teacher_layers
        
        # Layer mapping strategy: map student layers to teacher layers
        self.layer_mapping = self._create_layer_mapping()
        
        # Projection layers if dimensions don't match
        if student_dim != teacher_dim:
            self.projections = nn.ModuleList([
                nn.Linear(student_dim, teacher_dim) 
                for _ in range(num_student_layers)
            ])
        else:
            self.projections = None
    
    def _create_layer_mapping(self):
        # Map student layers uniformly across teacher layers
        # For 6 student, 12 teacher: [1, 3, 5, 7, 9, 11]
        step = self.num_teacher_layers / self.num_student_layers
        return [int(i * step) for i in range(self.num_student_layers)]
    
    def forward(self, student_hidden_states, teacher_hidden_states):
        """
        Args:
            student_hidden_states: List of tensors [batch, seq_len, student_dim]
            teacher_hidden_states: List of tensors [batch, seq_len, teacher_dim]
        """
        total_loss = 0
        
        for student_idx, teacher_idx in enumerate(self.layer_mapping):
            student_hidden = student_hidden_states[student_idx]
            teacher_hidden = teacher_hidden_states[teacher_idx]
            
            # Project student to teacher dimension if needed
            if self.projections is not None:
                student_hidden = self.projections[student_idx](student_hidden)
            
            # MSE loss between intermediate representations
            layer_loss = F.mse_loss(student_hidden, teacher_hidden.detach())
            total_loss += layer_loss
        
        return total_loss / len(self.layer_mapping)

Transferring Attention Patterns

Attention mechanisms in transformers capture complex dependencies between input tokens, encoding structure, relationships, and context. Distilling these attention patterns is harder than distilling outputs because attention matrices are large and sensitive to architectural differences. Attention transfer methods usually focus on preserving the structure of attention rather than exact weights.

The attention transfer loss measures the distance between teacher and student attention distributions, typically using mean squared error or KL divergence. You need to handle different numbers of attention heads. Some approaches average attention across heads before computing loss. Others maintain head-specific alignments, which preserves more detail but requires careful mapping.

class AttentionTransfer(nn.Module):
    def __init__(self, student_heads, teacher_heads, use_head_mapping=True):
        super().__init__()
        self.student_heads = student_heads
        self.teacher_heads = teacher_heads
        self.use_head_mapping = use_head_mapping
        
        if use_head_mapping and student_heads != teacher_heads:
            # Learn which teacher heads to map to which student heads
            self.head_mapping = nn.Parameter(
                torch.randn(student_heads, teacher_heads)
            )
    
    def forward(self, student_attentions, teacher_attentions):
        """
        Args:
            student_attentions: [batch, num_heads, seq_len, seq_len]
            teacher_attentions: [batch, num_heads, seq_len, seq_len]
        """
        batch_size, _, seq_len, _ = student_attentions.shape
        
        if self.use_head_mapping and hasattr(self, 'head_mapping'):
            # Apply learned head mapping
            mapping_weights = F.softmax(self.head_mapping, dim=1)
            # [student_heads, teacher_heads] × [batch, teacher_heads, seq, seq]
            teacher_mapped = torch.einsum(
                'st,bthw->bshw', 
                mapping_weights, 
                teacher_attentions
            )
        else:
            # Simple averaging if heads match or no mapping desired
            if self.student_heads == self.teacher_heads:
                teacher_mapped = teacher_attentions
            else:
                # Average teacher heads to match student count
                teacher_mapped = teacher_attentions.reshape(
                    batch_size, self.student_heads, -1, seq_len, seq_len
                ).mean(dim=2)
        
        # MSE between attention distributions
        attention_loss = F.mse_loss(student_attentions, teacher_mapped.detach())
        
        return attention_loss

Progressive Distillation

Progressive distillation solves the problem of distilling very large teachers into very small students by using intermediate teachers. Instead of going straight from a GPT-3 scale model to a mobile-friendly size, you create a series of progressively smaller teachers, each distilled from the previous one. This staged approach lets each student learn from a teacher closer to its own capacity, which reduces the knowledge gap and improves final performance.

Curriculum learning in distillation means carefully ordering training examples. Early on, the student learns from easier examples where the teacher is more confident. As training goes on, you introduce more ambiguous cases where the teacher's soft labels provide maximum information. You can define this curriculum based on prediction entropy, loss magnitude, or example complexity.

class ProgressiveDistillationTrainer:
    def __init__(self, teachers, student, device='cuda'):
        """
        Args:
            teachers: List of teacher models ordered from largest to smallest
            student: Student model to train
        """
        self.teachers = teachers
        self.student = student
        self.device = device
        
        # Move all models to device and set teachers to eval
        for teacher in self.teachers:
            teacher.to(device)
            teacher.eval()
        self.student.to(device)
    
    def get_curriculum_weight(self, epoch, total_epochs):
        # Linearly increase difficulty over training
        return min(1.0, epoch / (total_epochs * 0.7))
    
    def compute_example_difficulty(self, teacher_logits):
        # Use entropy of teacher predictions as difficulty measure
        probs = F.softmax(teacher_logits, dim=-1)
        entropy = -(probs * torch.log(probs + 1e-10)).sum(dim=-1)
        return entropy
    
    def progressive_distill(self, dataloader, stage, optimizer, 
                           temperature=3.0, epochs=10):
        """
        Distill from teachers[stage] into student or next teacher
        """
        current_teacher = self.teachers[stage]
        criterion = DistillationLoss(temperature=temperature)
        
        for epoch in range(epochs):
            curriculum_weight = self.get_curriculum_weight(epoch, epochs)
            
            for batch in dataloader:
                inputs, labels = batch
                inputs = inputs.to(self.device)
                labels = labels.to(self.device)
                
                # Get teacher predictions
                with torch.no_grad():
                    teacher_logits = current_teacher(inputs)
                    difficulties = self.compute_example_difficulty(teacher_logits)
                
                # Filter or weight examples based on curriculum
                difficulty_threshold = torch.quantile(
                    difficulties, curriculum_weight
                )
                example_weights = (difficulties <= difficulty_threshold).float()
                
                # Student forward pass
                student_logits = self.student(inputs)
                
                # Compute weighted distillation loss
                loss = criterion(student_logits, teacher_logits, labels)
                weighted_loss = (loss * example_weights.mean())
                
                # Optimization step
                optimizer.zero_grad()
                weighted_loss.backward()
                optimizer.step()
    
    def train_all_stages(self, dataloader, optimizer, epochs_per_stage=10):
        """
        Execute progressive distillation through all teacher stages
        """
        for stage in range(len(self.teachers)):
            print(f"Stage {stage}: Distilling from teacher {stage}")
            self.progressive_distill(
                dataloader, stage, optimizer, epochs=epochs_per_stage
            )
        
        return self.student

Task-Specific and Multi-Task Distillation

General distillation trains students to match teacher behavior across all tasks. Task-specific distillation optimizes for particular applications. This lets you compress more aggressively because the student only needs knowledge relevant to the target task. For example, distilling a general language model into a sentiment classifier can achieve much higher compression while maintaining or exceeding task performance.

Multi-task distillation extends this by training the student on multiple related tasks at once. The teacher might be an ensemble of task-specific expert models, and the student learns to handle all tasks in one architecture. This works well when tasks share underlying patterns, letting the student develop shared representations that generalize across tasks.

class MultiTaskDistillation(nn.Module):
    def __init__(self, task_weights=None):
        super().__init__()
        self.task_weights = task_weights or {}
    
    def forward(self, student_outputs, teacher_outputs, task_names, labels):
        """
        Args:
            student_outputs: Dict mapping task names to student logits
            teacher_outputs: Dict mapping task names to teacher logits
            task_names: List of tasks in current batch
            labels: Dict mapping task names to ground truth labels
        """
        total_loss = 0
        task_losses = {}
        
        for task in task_names:
            # Task-specific distillation loss
            criterion = DistillationLoss(
                temperature=self.get_task_temperature(task),
                alpha=self.get_task_alpha(task)
            )
            
            task_loss = criterion(
                student_outputs[task],
                teacher_outputs[task],
                labels[task]
            )
            
            # Weight by task importance
            weight = self.task_weights.get(task, 1.0)
            total_loss += weight * task_loss
            task_losses[task] = task_loss.item()
        
        return total_loss, task_losses
    
    def get_task_temperature(self, task):
        # Different tasks may benefit from different temperatures
        temperature_map = {
            'sentiment': 2.0,      # Lower for classification
            'nli': 3.0,            # Higher for complex reasoning
            'qa': 4.0,             # Highest for generation tasks
        }
        return temperature_map.get(task, 3.0)
    
    def get_task_alpha(self, task):
        # Balance between distillation and hard labels per task
        alpha_map = {
            'sentiment': 0.5,      # More weight on hard labels
            'nli': 0.7,            # Balanced
            'qa': 0.9,             # Heavy distillation weight
        }
        return alpha_map.get(task, 0.7)

Data Augmentation and Synthetic Data

How well distillation works depends heavily on the diversity and quality of training data. Basic distillation uses the same dataset that trained the teacher. Augmented distillation generates synthetic examples to expose the student to more teacher behaviors. The teacher generates labels for unlabeled data, greatly expanding the training set. This works especially well with task-specific augmentation strategies that target challenging cases or underrepresented patterns.

class DataAugmentedDistillation:
    def __init__(self, teacher, student, base_dataset):
        self.teacher = teacher
        self.student = student
        self.base_dataset = base_dataset
        
    def generate_synthetic_examples(self, num_examples, augmentation_fn):
        """
        Generate synthetic training examples using the teacher
        """
        synthetic_data = []
        self.teacher.eval()
        
        with torch.no_grad():
            for _ in range(num_examples):
                # Sample from base dataset and augment
                base_example = self.base_dataset[
                    torch.randint(len(self.base_dataset), (1,)).item()
                ]
                augmented_input = augmentation_fn(base_example)
                
                # Generate teacher predictions
                teacher_logits = self.teacher(augmented_input)
                
                synthetic_data.append({
                    'input': augmented_input,
                    'teacher_logits': teacher_logits.cpu(),
                    'source': 'synthetic'
                })
        
        return synthetic_data
    
    def hard_example_mining(self, dataloader, percentile=90):
        """
        Identify examples where student struggles most
        """
        self.student.eval()
        self.teacher.eval()
        
        example_difficulties = []
        
        with torch.no_grad():
            for batch in dataloader:
                inputs, labels = batch
                
                student_logits = self.student(inputs)
                teacher_logits = self.teacher(inputs)
                
                # Measure disagreement as difficulty proxy
                disagreement = F.kl_div(
                    F.log_softmax(student_logits, dim=-1),
                    F.softmax(teacher_logits, dim=-1),
                    reduction='none'
                ).sum(dim=-1)
                
                example_difficulties.extend(disagreement.cpu().numpy())
        
        # Return indices of hardest examples
        threshold = np.percentile(example_difficulties, percentile)
        hard_indices = np.where(
            np.array(example_difficulties) >= threshold
        )[0]
        
        return hard_indices

Training Stability and Optimization

The optimization landscape for distillation differs from standard supervised learning. The teacher's soft labels provide a smoother training signal than one-hot labels, which can speed up convergence but also cause instability if not managed carefully. Temperature directly affects gradient magnitudes, and improper tuning can lead to gradient explosion or vanishing gradients.

Learning rate scheduling is critical for success. A common strategy uses a warmup phase where the learning rate gradually increases, letting the student stabilize before full distillation. During main training, maintain a moderate learning rate with high temperature. Finally, a fine-tuning phase with reduced temperature and learning rate polishes performance.

class DistillationOptimizer:
    def __init__(self, student, initial_lr=1e-4, warmup_steps=1000):
        self.student = student
        self.initial_lr = initial_lr
        self.warmup_steps = warmup_steps
        self.global_step = 0
        
        # Use AdamW with weight decay for better generalization
        self.optimizer = torch.optim.AdamW(
            student.parameters(),
            lr=initial_lr,
            betas=(0.9, 0.999),
            weight_decay=0.01
        )
        
        self.scheduler = self._create_scheduler()
    
    def _create_scheduler(self):
        # Cosine schedule with warmup
        from torch.optim.lr_scheduler import LambdaLR
        
        def lr_lambda(step):
            if step < self.warmup_steps:
                # Linear warmup
                return step / self.warmup_steps
            else:
                # Cosine decay
                progress = (step - self.warmup_steps) / (10000 - self.warmup_steps)
                return 0.5 * (1 + np.cos(np.pi * progress))
        
        return LambdaLR(self.optimizer, lr_lambda)
    
    def step(self, loss):
        # Gradient clipping for stability
        torch.nn.utils.clip_grad_norm_(self.student.parameters(), max_norm=1.0)
        
        self.optimizer.step()
        self.scheduler.step()
        self.global_step += 1
        
        return self.scheduler.get_last_lr()[0]
    
    def get_temperature_schedule(self, max_steps):
        """
        Dynamic temperature scheduling during training
        """
        if self.global_step < self.warmup_steps:
            # Start with lower temperature during warmup
            return 2.0
        elif self.global_step < max_steps * 0.8:
            # Higher temperature for main distillation
            return 4.0
        else:
            # Reduce temperature for fine-tuning
            return 2.0

Evaluation Metrics

Evaluating distilled models requires more than just accuracy. You need to assess compression ratio, inference latency, memory footprint, and energy consumption. The distillation efficiency metric captures the trade-off between size reduction and performance retention, typically computed as the ratio of accuracy preservation to compression ratio.

class DistillationEvaluator:
    def __init__(self, teacher, student, test_loader, device='cuda'):
        self.teacher = teacher
        self.student = student
        self.test_loader = test_loader
        self.device = device
    
    def compute_compression_metrics(self):
        teacher_params = sum(p.numel() for p in self.teacher.parameters())
        student_params = sum(p.numel() for p in self.student.parameters())
        
        compression_ratio = teacher_params / student_params
        
        return {
            'teacher_parameters': teacher_params,
            'student_parameters': student_params,
            'compression_ratio': compression_ratio
        }
    
    def measure_inference_speed(self, num_samples=100):
        import time
        
        self.teacher.eval()
        self.student.eval()
        
        # Sample random inputs
        sample_inputs = []
        for batch in self.test_loader:
            sample_inputs.append(batch[0][:1].to(self.device))
            if len(sample_inputs) >= num_samples:
                break
        
        # Teacher inference time
        teacher_times = []
        with torch.no_grad():
            for inputs in sample_inputs:
                start = time.perf_counter()
                _ = self.teacher(inputs)
                teacher_times.append(time.perf_counter() - start)
        
        # Student inference time
        student_times = []
        with torch.no_grad():
            for inputs in sample_inputs:
                start = time.perf_counter()
                _ = self.student(inputs)
                student_times.append(time.perf_counter() - start)
        
        speedup = np.mean(teacher_times) / np.mean(student_times)
        
        return {
            'teacher_latency_ms': np.mean(teacher_times) * 1000,
            'student_latency_ms': np.mean(student_times) * 1000,
            'speedup_factor': speedup
        }
    
    def compute_agreement_metrics(self):
        """
        Measure how well student predictions agree with teacher
        """
        self.teacher.eval()
        self.student.eval()
        
        total_kl = 0
        total_top1_agreement = 0
        total_samples = 0
        
        with torch.no_grad():
            for inputs, labels in self.test_loader:
                inputs = inputs.to(self.device)
                
                teacher_logits = self.teacher(inputs)
                student_logits = self.student(inputs)
                
                # KL divergence
                kl = F.kl_div(
                    F.log_softmax(student_logits, dim=-1),
                    F.softmax(teacher_logits, dim=-1),
                    reduction='batchmean'
                )
                total_kl += kl.item() * inputs.size(0)
                
                # Top-1 agreement
                teacher_preds = teacher_logits.argmax(dim=-1)
                student_preds = student_logits.argmax(dim=-1)
                agreement = (teacher_preds == student_preds).float().mean()
                total_top1_agreement += agreement.item() * inputs.size(0)
                
                total_samples += inputs.size(0)
        
        return {
            'average_kl_divergence': total_kl / total_samples,
            'top1_agreement': total_top1_agreement / total_samples
        }
    
    def full_evaluation(self):
        """
        Comprehensive evaluation of distillation quality
        """
        metrics = {}
        
        # Compression metrics
        metrics.update(self.compute_compression_metrics())
        
        # Speed metrics
        metrics.update(self.measure_inference_speed())
        
        # Agreement metrics
        metrics.update(self.compute_agreement_metrics())
        
        # Efficiency score: accuracy preservation per unit compression
        metrics['efficiency_score'] = (
            metrics['top1_agreement'] * metrics['compression_ratio']
        )
        
        return metrics

Advanced Techniques

Recent advances have introduced several techniques that go beyond traditional knowledge transfer. Online distillation trains teacher and student simultaneously, with the teacher continuously updating rather than staying frozen. This co-evolution can lead to mutually beneficial learning where the student's progress informs teacher updates. Self-distillation applies distillation to the same architecture, using ensemble predictions or differently initialized models as teachers, which can improve performance even without compression.

Born-again networks are an extreme form of self-distillation where a student with the same architecture as the teacher often beats the teacher's performance. This suggests distillation provides more than just compression—it offers an improved optimization landscape and implicit regularization. Applying born-again distillation iteratively, where each generation serves as the teacher for the next, can progressively improve performance until it converges.

class OnlineDistillation(nn.Module):
    def __init__(self, teacher, student, teacher_update_freq=10):
        super().__init__()
        self.teacher = teacher
        self.student = student
        self.teacher_update_freq = teacher_update_freq
        self.step_count = 0
        
        # Initialize teacher with student parameters
        self.teacher.load_state_dict(student.state_dict())
        
        # Separate optimizers for teacher and student
        self.teacher_optimizer = torch.optim.AdamW(
            teacher.parameters(), lr=1e-5
        )
        self.student_optimizer = torch.optim.AdamW(
            student.parameters(), lr=1e-4
        )
    
    def train_step(self, inputs, labels, temperature=3.0):
        # Student learning from current teacher
        with torch.no_grad():
            teacher_logits = self.teacher(inputs)
        
        student_logits = self.student(inputs)
        
        criterion = DistillationLoss(temperature=temperature)
        student_loss = criterion(student_logits, teacher_logits, labels)
        
        self.student_optimizer.zero_grad()
        student_loss.backward()
        self.student_optimizer.step()
        
        # Periodically update teacher
        self.step_count += 1
        if self.step_count % self.teacher_update_freq == 0:
            # Teacher learns from student's predictions
            with torch.no_grad():
                student_logits_detached = self.student(inputs)
            
            teacher_logits = self.teacher(inputs)
            teacher_loss = criterion(teacher_logits, student_logits_detached, labels)
            
            self.teacher_optimizer.zero_grad()
            teacher_loss.backward()
            self.teacher_optimizer.step()
        
        return student_loss.item()

Summary

Language model distillation enables knowledge transfer from large, expensive teachers to efficient students. The techniques include output distribution matching, intermediate representation alignment, attention transfer, and progressive multi-stage distillation. Success depends on temperature scaling, curriculum learning, optimization dynamics, and architecture mapping.

The field continues to develop with innovations in online distillation, cross-modal knowledge transfer, and task-specific compression. As language models grow larger, distillation becomes necessary for making state-of-the-art natural language understanding accessible to more people. The core insight is straightforward: knowledge encoded in billions of parameters can be compressed into millions while preserving the essential patterns that drive intelligent behavior.