WHAT IS CURRICULUM LEARNING?
Curriculum learning is a training strategy in machine learning where you teach a model by presenting training examples in a meaningful order, starting from easier examples and gradually progressing to more difficult ones. This approach mimics how humans learn, where we typically master simple concepts before tackling complex ones.
Imagine teaching a child mathematics. You would not start with calculus. Instead, you would begin with counting, then addition, then subtraction, and slowly build up to more advanced topics. Curriculum learning applies this same principle to training artificial intelligence systems.
The fundamental idea is that the order in which a machine learning model sees training data can significantly impact how well and how quickly it learns. By carefully organizing training examples from simple to complex, we can often achieve better final performance, faster convergence, and improved generalization compared to randomly shuffling all training data.
WHY USE CURRICULUM LEARNING?
Traditional machine learning training typically shuffles all available training data and presents it randomly to the model. While this works, it has limitations. When a model encounters extremely difficult examples early in training, it may struggle to make progress because it has not yet developed the foundational patterns needed to understand these complex cases.
Curriculum learning addresses several important challenges. First, it can help models converge faster by building knowledge incrementally. Second, it can lead to better final performance by ensuring the model develops robust foundational representations before tackling edge cases. Third, it can improve training stability by avoiding overwhelming the model with complexity too early.
Consider training a computer vision model to recognize objects in cluttered scenes. If we start with clear, well-lit images of single objects, the model can learn basic shape and color patterns. Once it masters these fundamentals, we can introduce images with multiple objects, then images with occlusions, and finally images with poor lighting and heavy clutter. This progression allows the model to build capabilities systematically.
THE CORE COMPONENTS OF CURRICULUM LEARNING
A curriculum learning system consists of several essential components that work together to implement the progressive training strategy.
The first component is the difficulty measurer. This component assigns a difficulty score to each training example. The difficulty measure can be based on various factors such as the complexity of the input, the rarity of the label, or even the model's current performance on similar examples.
The second component is the pacing function. This determines how quickly the curriculum should progress from easy to hard examples. Some curricula use a fixed schedule, while others adapt based on the model's learning progress.
The third component is the data scheduler. This takes the difficulty scores and pacing function to decide which examples should be presented to the model at each training step. The scheduler might use strategies like filtering out examples above a certain difficulty threshold or adjusting the sampling probability based on difficulty.
The fourth component is the training loop itself, which integrates these elements with the standard model training process. The training loop must coordinate between curriculum progression and model optimization.
MEASURING EXAMPLE DIFFICULTY
Determining which examples are easy and which are hard is a crucial challenge in curriculum learning. There are several approaches to measuring difficulty, each with different strengths and use cases.
One straightforward approach is to use predefined heuristics based on domain knowledge. For instance, in language learning, sentence length might serve as a difficulty proxy. Shorter sentences are generally easier to process than longer ones. In image classification, images with clear backgrounds might be considered easier than those with cluttered scenes.
Here is a simple example of a heuristic-based difficulty scorer for text data:
class TextDifficultyScorer:
def __init__(self, vocab_frequency):
# vocab_frequency is a dictionary mapping words to their corpus frequency
self.vocab_frequency = vocab_frequency
def score(self, text):
# Tokenize the text into words
words = text.lower().split()
# Calculate difficulty based on length and rare words
length_score = len(words) / 100.0 # Normalize by expected max length
# Calculate rarity score (inverse of average word frequency)
rarity_scores = []
for word in words:
freq = self.vocab_frequency.get(word, 0.0001) # Default for unknown words
rarity_scores.append(1.0 / (freq + 0.0001))
avg_rarity = sum(rarity_scores) / max(len(rarity_scores), 1)
# Combine scores (you can adjust weights)
difficulty = 0.3 * length_score + 0.7 * avg_rarity
return difficulty
This scorer combines two factors: the length of the text and the rarity of words it contains. Longer texts with rare words receive higher difficulty scores.
Another approach is to use model-based difficulty estimation. In this method, we train a separate model or use the current model's predictions to estimate difficulty. Examples where the model makes confident correct predictions are considered easy, while examples where the model is uncertain or incorrect are considered hard.
A third approach is to use loss-based difficulty. We can measure how much loss the model incurs on each example. Examples with high loss are difficult, while those with low loss are easy. This approach has the advantage of being adaptive: as the model learns, what was once difficult may become easy.
Now let us learn an example of a loss-based difficulty tracker:
import numpy as np
class LossBasedDifficultyTracker:
def __init__(self, smoothing_factor=0.9):
# Store moving average of loss for each example
self.example_losses = {}
self.smoothing_factor = smoothing_factor
def update(self, example_id, loss_value):
# Update the moving average loss for this example
if example_id not in self.example_losses:
self.example_losses[example_id] = loss_value
else:
# Exponential moving average
old_loss = self.example_losses[example_id]
new_loss = self.smoothing_factor * old_loss + (1 - self.smoothing_factor) * loss_value
self.example_losses[example_id] = new_loss
def get_difficulty(self, example_id):
# Return the current difficulty estimate
return self.example_losses.get(example_id, float('inf'))
def get_difficulty_percentile(self, example_id):
# Return what percentile this example falls into
if example_id not in self.example_losses:
return 1.0 # Treat unseen examples as hardest
all_losses = list(self.example_losses.values())
example_loss = self.example_losses[example_id]
# Calculate percentile
percentile = sum(1 for loss in all_losses if loss <= example_loss) / len(all_losses)
return percentile
This tracker maintains a moving average of the loss for each training example. As training progresses, it updates these estimates, allowing the curriculum to adapt to the model's changing capabilities.
PACING STRATEGIES FOR CURRICULUM PROGRESSION
Once we have difficulty scores for our training examples, we need to decide how to pace the curriculum. The pacing strategy determines how quickly we transition from easy to hard examples.
The simplest approach is a fixed linear schedule. We might start by only showing examples in the easiest twenty percent, then after a certain number of training steps, expand to the easiest forty percent, and so on until we are using all examples.
An implementation of a fixed linear pacing function:
class LinearPacingFunction:
def __init__(self, total_steps, start_percentile=0.2, end_percentile=1.0):
# total_steps: how many training steps until we use all data
# start_percentile: what fraction of easiest data to start with
# end_percentile: what fraction to end with (usually 1.0 for all data)
self.total_steps = total_steps
self.start_percentile = start_percentile
self.end_percentile = end_percentile
def get_difficulty_threshold(self, current_step):
# Calculate what percentile of data we should include at this step
if current_step >= self.total_steps:
return self.end_percentile
progress = current_step / self.total_steps
threshold = self.start_percentile + progress * (self.end_percentile - self.start_percentile)
return threshold
This pacing function starts by allowing only the easiest twenty percent of examples and linearly increases this threshold until all examples are included after the specified number of training steps.
A more sophisticated approach is self-paced learning, where the curriculum adapts based on the model's performance. If the model is learning quickly and achieving low loss, the curriculum might accelerate and introduce harder examples sooner. If the model struggles, the curriculum might slow down and spend more time on easier examples.
Let us view a self-paced learning implementation:
class SelfPacedCurriculum:
def __init__(self, initial_threshold=0.2, growth_rate=0.01, performance_window=100):
# initial_threshold: starting difficulty percentile
# growth_rate: how much to increase threshold when performing well
# performance_window: how many recent steps to consider for performance
self.current_threshold = initial_threshold
self.growth_rate = growth_rate
self.performance_window = performance_window
self.recent_losses = []
self.loss_trend = None
def update(self, current_loss):
# Track recent losses to determine if model is improving
self.recent_losses.append(current_loss)
# Keep only the most recent losses
if len(self.recent_losses) > self.performance_window:
self.recent_losses.pop(0)
# Calculate loss trend (negative means improving)
if len(self.recent_losses) >= 2:
recent_avg = np.mean(self.recent_losses[-20:]) if len(self.recent_losses) >= 20 else self.recent_losses[-1]
older_avg = np.mean(self.recent_losses[:20]) if len(self.recent_losses) >= 40 else self.recent_losses[0]
self.loss_trend = recent_avg - older_avg
def get_difficulty_threshold(self):
# Adjust threshold based on learning progress
if self.loss_trend is not None and self.loss_trend < 0:
# Model is improving, increase difficulty
self.current_threshold = min(1.0, self.current_threshold + self.growth_rate)
elif self.loss_trend is not None and self.loss_trend > 0:
# Model is struggling, slow down or maintain current difficulty
self.current_threshold = max(0.1, self.current_threshold - self.growth_rate * 0.5)
return self.current_threshold
This self-paced curriculum monitors the model's recent loss trend. When the model is improving, it increases the difficulty threshold to introduce harder examples. When the model struggles, it reduces the threshold or maintains the current difficulty level.
IMPLEMENTING THE DATA SCHEDULER
The data scheduler is responsible for selecting which training examples to present at each step based on the difficulty scores and pacing function. There are several strategies for implementing this selection.
One approach is hard filtering, where we completely exclude examples above the current difficulty threshold. This ensures the model only sees examples it is ready for, but it can be wasteful because we discard potentially useful data.
Another approach is soft filtering or importance sampling, where we adjust the probability of sampling each example based on its difficulty. Easier examples get higher sampling probability, but harder examples are not completely excluded. This allows the model to occasionally encounter challenging examples while focusing primarily on appropriate difficulty levels.
Here comes an implementation of a curriculum data scheduler with both hard and soft filtering options:
import random
class CurriculumDataScheduler:
def __init__(self, dataset, difficulty_scorer, pacing_function, mode='soft'):
# dataset: list of training examples with unique IDs
# difficulty_scorer: object that can score example difficulty
# pacing_function: object that determines current difficulty threshold
# mode: 'hard' for filtering, 'soft' for importance sampling
self.dataset = dataset
self.difficulty_scorer = difficulty_scorer
self.pacing_function = pacing_function
self.mode = mode
# Pre-compute difficulty scores for all examples
self.difficulty_scores = {}
for example in dataset:
example_id = example['id']
self.difficulty_scores[example_id] = difficulty_scorer.score(example)
# Normalize scores to [0, 1] range
max_score = max(self.difficulty_scores.values())
min_score = min(self.difficulty_scores.values())
score_range = max_score - min_score
if score_range > 0:
for example_id in self.difficulty_scores:
normalized = (self.difficulty_scores[example_id] - min_score) / score_range
self.difficulty_scores[example_id] = normalized
def get_batch(self, batch_size, current_step):
# Get current difficulty threshold from pacing function
threshold = self.pacing_function.get_difficulty_threshold(current_step)
if self.mode == 'hard':
# Hard filtering: only include examples below threshold
eligible_examples = [
ex for ex in self.dataset
if self.difficulty_scores[ex['id']] <= threshold
]
if len(eligible_examples) < batch_size:
# Not enough easy examples, use what we have
batch = eligible_examples
else:
# Randomly sample from eligible examples
batch = random.sample(eligible_examples, batch_size)
else: # soft mode
# Soft filtering: sample with probability inversely proportional to difficulty
sampling_weights = []
for example in self.dataset:
difficulty = self.difficulty_scores[example['id']]
# Examples at or below threshold get full weight
# Examples above threshold get reduced weight
if difficulty <= threshold:
weight = 1.0
else:
# Exponentially decay weight for harder examples
excess_difficulty = difficulty - threshold
weight = np.exp(-5.0 * excess_difficulty)
sampling_weights.append(weight)
# Normalize weights to probabilities
total_weight = sum(sampling_weights)
probabilities = [w / total_weight for w in sampling_weights]
# Sample according to these probabilities
indices = np.random.choice(
len(self.dataset),
size=batch_size,
replace=False,
p=probabilities
)
batch = [self.dataset[i] for i in indices]
return batch
This scheduler can operate in two modes. In hard filtering mode, it completely excludes examples above the difficulty threshold. In soft filtering mode, it uses importance sampling to preferentially select easier examples while still occasionally including harder ones.
INTEGRATING CURRICULUM LEARNING INTO TRAINING
Now that we have all the components, we need to integrate them into the actual training loop. The training loop must coordinate between curriculum progression, batch selection, model updates, and difficulty tracking.
Here is a skeleton of a curriculum learning training loop:
def train_with_curriculum(model, optimizer, dataset, difficulty_scorer,
pacing_function, num_epochs, batch_size):
# Initialize the curriculum scheduler
scheduler = CurriculumDataScheduler(
dataset,
difficulty_scorer,
pacing_function,
mode='soft'
)
# Track training progress
global_step = 0
for epoch in range(num_epochs):
epoch_loss = 0.0
num_batches = len(dataset) // batch_size
for batch_idx in range(num_batches):
# Get a curriculum-based batch
batch = scheduler.get_batch(batch_size, global_step)
# Forward pass
predictions = model(batch)
loss = compute_loss(predictions, batch)
# Backward pass and optimization
optimizer.zero_grad()
loss.backward()
optimizer.step()
# Update difficulty scores if using adaptive scoring
if hasattr(difficulty_scorer, 'update'):
for example in batch:
example_loss = compute_example_loss(model, example)
difficulty_scorer.update(example['id'], example_loss)
# Update pacing function if using self-paced learning
if hasattr(pacing_function, 'update'):
pacing_function.update(loss.item())
epoch_loss += loss.item()
global_step += 1
avg_epoch_loss = epoch_loss / num_batches
print(f"Epoch {epoch + 1}/{num_epochs}, Loss: {avg_epoch_loss:.4f}")
return model
This training loop integrates all curriculum learning components. It uses the scheduler to get appropriately difficult batches, performs standard model training, and updates both the difficulty scorer and pacing function based on training progress.
PRACTICAL CONSIDERATIONS AND BEST PRACTICES
When implementing curriculum learning in practice, several important considerations can affect success.
First, the choice of difficulty measure is crucial and domain-dependent. For some tasks, simple heuristics work well. For others, adaptive loss-based measures are necessary. It is often beneficial to experiment with multiple difficulty measures and compare their effectiveness.
Second, the pacing schedule requires careful tuning. If the curriculum progresses too quickly, the model may not have time to master easier concepts before encountering harder ones. If it progresses too slowly, training time increases without corresponding benefits. Self-paced learning can help automate this tuning but introduces its own hyperparameters.
Third, curriculum learning interacts with other training techniques. When using techniques like data augmentation, learning rate schedules, or regularization, these must be coordinated with the curriculum. For example, you might want to increase data augmentation as the curriculum introduces harder examples.
Fourth, not all tasks benefit equally from curriculum learning. Tasks with clear difficulty hierarchies and where foundational concepts enable learning of advanced concepts tend to benefit most. Tasks where examples are relatively uniform in difficulty may see little benefit.
Fifth, evaluation is important. You should compare curriculum learning against standard random shuffling on your specific task. Measure not just final performance but also convergence speed and training stability.
FULL PRODUCTION-READY RUNNING EXAMPLE
Now I will present a complete, production-ready implementation of curriculum learning for a text classification task. This implementation includes all necessary components and can be adapted to various use cases.
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from collections import defaultdict
import random
from typing import List, Dict, Tuple, Optional, Callable
import json
class TextDataset(Dataset):
"""
A dataset class for text classification that supports curriculum learning.
Each example has a unique ID for tracking difficulty scores.
"""
def __init__(self, texts: List[str], labels: List[int], vocab: Dict[str, int]):
"""
Initialize the dataset.
Args:
texts: List of text strings
labels: List of integer labels
vocab: Dictionary mapping words to integer indices
"""
self.texts = texts
self.labels = labels
self.vocab = vocab
self.max_length = 100
def __len__(self):
return len(self.texts)
def __getitem__(self, idx):
text = self.texts[idx]
label = self.labels[idx]
# Tokenize and convert to indices
tokens = text.lower().split()
indices = [self.vocab.get(token, self.vocab['<UNK>']) for token in tokens]
# Pad or truncate to max_length
if len(indices) < self.max_length:
indices = indices + [self.vocab['<PAD>']] * (self.max_length - len(indices))
else:
indices = indices[:self.max_length]
return {
'id': idx,
'text': text,
'indices': torch.tensor(indices, dtype=torch.long),
'label': torch.tensor(label, dtype=torch.long)
}
class TextClassifier(nn.Module):
"""
A simple LSTM-based text classifier.
"""
def __init__(self, vocab_size: int, embedding_dim: int, hidden_dim: int,
num_classes: int, dropout: float = 0.3):
"""
Initialize the classifier.
Args:
vocab_size: Size of the vocabulary
embedding_dim: Dimension of word embeddings
hidden_dim: Dimension of LSTM hidden state
num_classes: Number of output classes
dropout: Dropout probability
"""
super(TextClassifier, self).__init__()
self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0)
self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first=True,
num_layers=2, dropout=dropout, bidirectional=True)
self.dropout = nn.Dropout(dropout)
self.fc = nn.Linear(hidden_dim * 2, num_classes)
def forward(self, indices):
"""
Forward pass.
Args:
indices: Tensor of shape (batch_size, max_length) containing word indices
Returns:
Tensor of shape (batch_size, num_classes) containing class logits
"""
# Embed the input
embedded = self.embedding(indices) # (batch_size, max_length, embedding_dim)
# Pass through LSTM
lstm_out, (hidden, cell) = self.lstm(embedded)
# Use the final hidden states from both directions
# hidden shape: (num_layers * num_directions, batch_size, hidden_dim)
forward_hidden = hidden[-2, :, :]
backward_hidden = hidden[-1, :, :]
combined_hidden = torch.cat([forward_hidden, backward_hidden], dim=1)
# Apply dropout and final linear layer
dropped = self.dropout(combined_hidden)
logits = self.fc(dropped)
return logits
class HeuristicDifficultyScorer:
"""
Scores text difficulty based on heuristics like length and word rarity.
"""
def __init__(self, vocab_frequency: Dict[str, float]):
"""
Initialize the scorer.
Args:
vocab_frequency: Dictionary mapping words to their frequency in corpus
"""
self.vocab_frequency = vocab_frequency
def score(self, example: Dict) -> float:
"""
Compute difficulty score for an example.
Args:
example: Dictionary containing 'text' key
Returns:
Float difficulty score (higher = more difficult)
"""
text = example['text']
words = text.lower().split()
if len(words) == 0:
return 0.0
# Length component (normalized)
length_score = min(len(words) / 50.0, 1.0)
# Rarity component (average inverse frequency)
rarity_scores = []
for word in words:
freq = self.vocab_frequency.get(word, 0.00001)
rarity_scores.append(1.0 / (freq + 0.00001))
avg_rarity = np.mean(rarity_scores)
# Normalize rarity score
rarity_score = min(avg_rarity / 1000.0, 1.0)
# Combine scores
difficulty = 0.4 * length_score + 0.6 * rarity_score
return difficulty
class AdaptiveDifficultyScorer:
"""
Scores difficulty based on model's current loss on each example.
Adapts as the model learns.
"""
def __init__(self, smoothing_factor: float = 0.9, initial_difficulty: float = 0.5):
"""
Initialize the scorer.
Args:
smoothing_factor: Factor for exponential moving average (0-1)
initial_difficulty: Initial difficulty for unseen examples
"""
self.smoothing_factor = smoothing_factor
self.initial_difficulty = initial_difficulty
self.example_losses = {}
def update(self, example_id: int, loss_value: float):
"""
Update the difficulty estimate for an example.
Args:
example_id: Unique identifier for the example
loss_value: Current loss value on this example
"""
if example_id not in self.example_losses:
self.example_losses[example_id] = loss_value
else:
old_loss = self.example_losses[example_id]
new_loss = (self.smoothing_factor * old_loss +
(1 - self.smoothing_factor) * loss_value)
self.example_losses[example_id] = new_loss
def score(self, example: Dict) -> float:
"""
Get difficulty score for an example.
Args:
example: Dictionary containing 'id' key
Returns:
Float difficulty score (higher = more difficult)
"""
example_id = example['id']
if example_id not in self.example_losses:
return self.initial_difficulty
# Normalize loss to [0, 1] range
all_losses = list(self.example_losses.values())
if len(all_losses) == 0:
return self.initial_difficulty
max_loss = max(all_losses)
min_loss = min(all_losses)
if max_loss == min_loss:
return 0.5
example_loss = self.example_losses[example_id]
normalized_difficulty = (example_loss - min_loss) / (max_loss - min_loss)
return normalized_difficulty
class FixedPacingFunction:
"""
Fixed linear pacing schedule that increases difficulty threshold over time.
"""
def __init__(self, total_steps: int, start_percentile: float = 0.2,
end_percentile: float = 1.0):
"""
Initialize the pacing function.
Args:
total_steps: Number of steps to reach end_percentile
start_percentile: Initial difficulty threshold (0-1)
end_percentile: Final difficulty threshold (0-1)
"""
self.total_steps = total_steps
self.start_percentile = start_percentile
self.end_percentile = end_percentile
def get_difficulty_threshold(self, current_step: int) -> float:
"""
Get the current difficulty threshold.
Args:
current_step: Current training step
Returns:
Float threshold value (0-1)
"""
if current_step >= self.total_steps:
return self.end_percentile
progress = current_step / self.total_steps
threshold = (self.start_percentile +
progress * (self.end_percentile - self.start_percentile))
return threshold
class AdaptivePacingFunction:
"""
Self-paced learning that adjusts difficulty based on model performance.
"""
def __init__(self, initial_threshold: float = 0.2, min_threshold: float = 0.1,
max_threshold: float = 1.0, growth_rate: float = 0.005,
performance_window: int = 100):
"""
Initialize the pacing function.
Args:
initial_threshold: Starting difficulty threshold
min_threshold: Minimum allowed threshold
max_threshold: Maximum allowed threshold
growth_rate: How much to adjust threshold each step
performance_window: Number of recent losses to track
"""
self.current_threshold = initial_threshold
self.min_threshold = min_threshold
self.max_threshold = max_threshold
self.growth_rate = growth_rate
self.performance_window = performance_window
self.recent_losses = []
def update(self, current_loss: float):
"""
Update the pacing based on current performance.
Args:
current_loss: Current training loss
"""
self.recent_losses.append(current_loss)
if len(self.recent_losses) > self.performance_window:
self.recent_losses.pop(0)
# Calculate if model is improving
if len(self.recent_losses) >= 20:
recent_avg = np.mean(self.recent_losses[-10:])
older_avg = np.mean(self.recent_losses[:10])
if recent_avg < older_avg:
# Model improving, increase difficulty
self.current_threshold = min(
self.max_threshold,
self.current_threshold + self.growth_rate
)
else:
# Model struggling, decrease difficulty slightly
self.current_threshold = max(
self.min_threshold,
self.current_threshold - self.growth_rate * 0.3
)
def get_difficulty_threshold(self, current_step: int = None) -> float:
"""
Get the current difficulty threshold.
Args:
current_step: Not used in adaptive pacing, kept for interface compatibility
Returns:
Float threshold value (0-1)
"""
return self.current_threshold
class CurriculumScheduler:
"""
Schedules training batches according to curriculum learning strategy.
"""
def __init__(self, dataset: Dataset, difficulty_scorer,
pacing_function, mode: str = 'soft'):
"""
Initialize the scheduler.
Args:
dataset: Dataset to schedule
difficulty_scorer: Object with score(example) method
pacing_function: Object with get_difficulty_threshold(step) method
mode: 'hard' for filtering, 'soft' for importance sampling
"""
self.dataset = dataset
self.difficulty_scorer = difficulty_scorer
self.pacing_function = pacing_function
self.mode = mode
# Pre-compute initial difficulty scores
self.difficulty_scores = {}
self._update_all_difficulty_scores()
def _update_all_difficulty_scores(self):
"""
Recompute difficulty scores for all examples.
"""
for idx in range(len(self.dataset)):
example = self.dataset[idx]
self.difficulty_scores[idx] = self.difficulty_scorer.score(example)
def get_batch_indices(self, batch_size: int, current_step: int) -> List[int]:
"""
Get indices for the next batch according to curriculum.
Args:
batch_size: Number of examples to return
current_step: Current training step
Returns:
List of dataset indices
"""
threshold = self.pacing_function.get_difficulty_threshold(current_step)
if self.mode == 'hard':
# Hard filtering: only include examples below threshold
eligible_indices = [
idx for idx in range(len(self.dataset))
if self.difficulty_scores[idx] <= threshold
]
if len(eligible_indices) == 0:
# Fallback: use all examples if none are eligible
eligible_indices = list(range(len(self.dataset)))
if len(eligible_indices) <= batch_size:
return eligible_indices
else:
return random.sample(eligible_indices, batch_size)
else: # soft mode
# Importance sampling based on difficulty
weights = []
for idx in range(len(self.dataset)):
difficulty = self.difficulty_scores[idx]
if difficulty <= threshold:
weight = 1.0
else:
# Exponentially decay weight for harder examples
excess = difficulty - threshold
weight = np.exp(-5.0 * excess)
weights.append(weight)
# Normalize to probabilities
total_weight = sum(weights)
if total_weight == 0:
# Fallback to uniform sampling
probabilities = [1.0 / len(weights)] * len(weights)
else:
probabilities = [w / total_weight for w in weights]
# Sample indices
indices = np.random.choice(
len(self.dataset),
size=min(batch_size, len(self.dataset)),
replace=False,
p=probabilities
)
return indices.tolist()
class CurriculumLearningTrainer:
"""
Main trainer class that orchestrates curriculum learning.
"""
def __init__(self, model: nn.Module, dataset: Dataset,
difficulty_scorer, pacing_function,
device: str = 'cpu', scheduler_mode: str = 'soft'):
"""
Initialize the trainer.
Args:
model: PyTorch model to train
dataset: Training dataset
difficulty_scorer: Difficulty scoring object
pacing_function: Pacing function object
device: Device to train on ('cpu' or 'cuda')
scheduler_mode: 'hard' or 'soft' filtering
"""
self.model = model.to(device)
self.dataset = dataset
self.difficulty_scorer = difficulty_scorer
self.pacing_function = pacing_function
self.device = device
self.scheduler = CurriculumScheduler(
dataset, difficulty_scorer, pacing_function, scheduler_mode
)
self.criterion = nn.CrossEntropyLoss(reduction='none')
self.global_step = 0
def train_epoch(self, optimizer: optim.Optimizer, batch_size: int,
update_difficulty: bool = True) -> Tuple[float, float]:
"""
Train for one epoch.
Args:
optimizer: PyTorch optimizer
batch_size: Batch size
update_difficulty: Whether to update difficulty scores
Returns:
Tuple of (average_loss, accuracy)
"""
self.model.train()
total_loss = 0.0
total_correct = 0
total_examples = 0
num_batches = len(self.dataset) // batch_size
for batch_idx in range(num_batches):
# Get curriculum-based batch
indices = self.scheduler.get_batch_indices(batch_size, self.global_step)
# Gather batch data
batch_data = [self.dataset[idx] for idx in indices]
# Stack tensors
input_indices = torch.stack([item['indices'] for item in batch_data]).to(self.device)
labels = torch.stack([item['label'] for item in batch_data]).to(self.device)
# Forward pass
optimizer.zero_grad()
logits = self.model(input_indices)
# Compute loss
losses = self.criterion(logits, labels)
loss = losses.mean()
# Backward pass
loss.backward()
optimizer.step()
# Update difficulty scores if using adaptive scoring
if update_difficulty and hasattr(self.difficulty_scorer, 'update'):
for i, idx in enumerate(indices):
example_loss = losses[i].item()
self.difficulty_scorer.update(idx, example_loss)
# Update pacing function if adaptive
if hasattr(self.pacing_function, 'update'):
self.pacing_function.update(loss.item())
# Track metrics
total_loss += loss.item()
predictions = torch.argmax(logits, dim=1)
total_correct += (predictions == labels).sum().item()
total_examples += len(labels)
self.global_step += 1
avg_loss = total_loss / num_batches
accuracy = total_correct / total_examples
return avg_loss, accuracy
def evaluate(self, eval_dataset: Dataset, batch_size: int) -> Tuple[float, float]:
"""
Evaluate the model on a dataset.
Args:
eval_dataset: Dataset to evaluate on
batch_size: Batch size for evaluation
Returns:
Tuple of (average_loss, accuracy)
"""
self.model.eval()
total_loss = 0.0
total_correct = 0
total_examples = 0
with torch.no_grad():
for start_idx in range(0, len(eval_dataset), batch_size):
end_idx = min(start_idx + batch_size, len(eval_dataset))
batch_data = [eval_dataset[idx] for idx in range(start_idx, end_idx)]
input_indices = torch.stack([item['indices'] for item in batch_data]).to(self.device)
labels = torch.stack([item['label'] for item in batch_data]).to(self.device)
logits = self.model(input_indices)
losses = self.criterion(logits, labels)
total_loss += losses.sum().item()
predictions = torch.argmax(logits, dim=1)
total_correct += (predictions == labels).sum().item()
total_examples += len(labels)
avg_loss = total_loss / total_examples
accuracy = total_correct / total_examples
return avg_loss, accuracy
def train(self, num_epochs: int, batch_size: int, learning_rate: float,
eval_dataset: Optional[Dataset] = None,
eval_interval: int = 1) -> Dict[str, List[float]]:
"""
Full training loop.
Args:
num_epochs: Number of epochs to train
batch_size: Batch size
learning_rate: Learning rate
eval_dataset: Optional evaluation dataset
eval_interval: How often to evaluate (in epochs)
Returns:
Dictionary containing training history
"""
optimizer = optim.Adam(self.model.parameters(), lr=learning_rate)
history = {
'train_loss': [],
'train_accuracy': [],
'eval_loss': [],
'eval_accuracy': [],
'difficulty_threshold': []
}
for epoch in range(num_epochs):
# Train for one epoch
train_loss, train_acc = self.train_epoch(optimizer, batch_size)
history['train_loss'].append(train_loss)
history['train_accuracy'].append(train_acc)
# Track current difficulty threshold
current_threshold = self.pacing_function.get_difficulty_threshold(self.global_step)
history['difficulty_threshold'].append(current_threshold)
print(f"Epoch {epoch + 1}/{num_epochs}")
print(f" Train Loss: {train_loss:.4f}, Train Acc: {train_acc:.4f}")
print(f" Difficulty Threshold: {current_threshold:.4f}")
# Evaluate if requested
if eval_dataset is not None and (epoch + 1) % eval_interval == 0:
eval_loss, eval_acc = self.evaluate(eval_dataset, batch_size)
history['eval_loss'].append(eval_loss)
history['eval_accuracy'].append(eval_acc)
print(f" Eval Loss: {eval_loss:.4f}, Eval Acc: {eval_acc:.4f}")
print()
return history
def create_synthetic_dataset(num_examples: int, num_classes: int,
vocab_size: int = 1000) -> Tuple[List[str], List[int], Dict[str, int], Dict[str, float]]:
"""
Create a synthetic text classification dataset for demonstration.
Args:
num_examples: Number of examples to generate
num_classes: Number of classes
vocab_size: Size of vocabulary
Returns:
Tuple of (texts, labels, vocab, vocab_frequency)
"""
# Create vocabulary
vocab = {'<PAD>': 0, '<UNK>': 1}
for i in range(vocab_size):
vocab[f'word_{i}'] = i + 2
# Create word frequency distribution (Zipf-like)
vocab_frequency = {}
for word, idx in vocab.items():
if word not in ['<PAD>', '<UNK>']:
# Zipf distribution: frequency inversely proportional to rank
rank = idx
frequency = 1.0 / (rank ** 0.8)
vocab_frequency[word] = frequency
# Generate texts and labels
texts = []
labels = []
for i in range(num_examples):
# Label determines text characteristics
label = i % num_classes
# Easy examples: short, common words
# Hard examples: long, rare words
if i < num_examples * 0.3:
# Easy examples
length = random.randint(5, 15)
word_indices = random.choices(range(2, 100), k=length)
elif i < num_examples * 0.6:
# Medium examples
length = random.randint(15, 30)
word_indices = random.choices(range(2, 500), k=length)
else:
# Hard examples
length = random.randint(30, 60)
word_indices = random.choices(range(2, vocab_size + 2), k=length)
# Create text from word indices
words = [f'word_{idx - 2}' for idx in word_indices]
text = ' '.join(words)
texts.append(text)
labels.append(label)
return texts, labels, vocab, vocab_frequency
def main():
"""
Main function demonstrating curriculum learning usage.
"""
print("=" * 80)
print("CURRICULUM LEARNING DEMONSTRATION")
print("=" * 80)
print()
# Set random seeds for reproducibility
random.seed(42)
np.random.seed(42)
torch.manual_seed(42)
# Create synthetic dataset
print("Creating synthetic dataset...")
num_train = 1000
num_eval = 200
num_classes = 5
train_texts, train_labels, vocab, vocab_freq = create_synthetic_dataset(
num_train, num_classes
)
eval_texts, eval_labels, _, _ = create_synthetic_dataset(
num_eval, num_classes
)
print(f" Training examples: {num_train}")
print(f" Evaluation examples: {num_eval}")
print(f" Vocabulary size: {len(vocab)}")
print(f" Number of classes: {num_classes}")
print()
# Create datasets
train_dataset = TextDataset(train_texts, train_labels, vocab)
eval_dataset = TextDataset(eval_texts, eval_labels, vocab)
# Initialize model
print("Initializing model...")
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f" Using device: {device}")
model = TextClassifier(
vocab_size=len(vocab),
embedding_dim=128,
hidden_dim=256,
num_classes=num_classes,
dropout=0.3
)
print()
# Demonstrate different curriculum learning configurations
# Configuration 1: Heuristic difficulty with fixed pacing
print("-" * 80)
print("CONFIGURATION 1: Heuristic Difficulty + Fixed Pacing")
print("-" * 80)
difficulty_scorer_1 = HeuristicDifficultyScorer(vocab_freq)
pacing_function_1 = FixedPacingFunction(
total_steps=500,
start_percentile=0.2,
end_percentile=1.0
)
trainer_1 = CurriculumLearningTrainer(
model=model,
dataset=train_dataset,
difficulty_scorer=difficulty_scorer_1,
pacing_function=pacing_function_1,
device=device,
scheduler_mode='soft'
)
print("Training with heuristic difficulty and fixed pacing...")
history_1 = trainer_1.train(
num_epochs=5,
batch_size=32,
learning_rate=0.001,
eval_dataset=eval_dataset,
eval_interval=1
)
print()
# Configuration 2: Adaptive difficulty with self-paced learning
print("-" * 80)
print("CONFIGURATION 2: Adaptive Difficulty + Self-Paced Learning")
print("-" * 80)
# Reinitialize model for fair comparison
model_2 = TextClassifier(
vocab_size=len(vocab),
embedding_dim=128,
hidden_dim=256,
num_classes=num_classes,
dropout=0.3
)
difficulty_scorer_2 = AdaptiveDifficultyScorer(
smoothing_factor=0.9,
initial_difficulty=0.5
)
pacing_function_2 = AdaptivePacingFunction(
initial_threshold=0.2,
growth_rate=0.01,
performance_window=100
)
trainer_2 = CurriculumLearningTrainer(
model=model_2,
dataset=train_dataset,
difficulty_scorer=difficulty_scorer_2,
pacing_function=pacing_function_2,
device=device,
scheduler_mode='soft'
)
print("Training with adaptive difficulty and self-paced learning...")
history_2 = trainer_2.train(
num_epochs=5,
batch_size=32,
learning_rate=0.001,
eval_dataset=eval_dataset,
eval_interval=1
)
print()
# Configuration 3: Baseline (no curriculum, for comparison)
print("-" * 80)
print("CONFIGURATION 3: Baseline (No Curriculum)")
print("-" * 80)
# Reinitialize model
model_3 = TextClassifier(
vocab_size=len(vocab),
embedding_dim=128,
hidden_dim=256,
num_classes=num_classes,
dropout=0.3
)
# Use fixed pacing that immediately uses all data
difficulty_scorer_3 = HeuristicDifficultyScorer(vocab_freq)
pacing_function_3 = FixedPacingFunction(
total_steps=1, # Immediately use all data
start_percentile=1.0,
end_percentile=1.0
)
trainer_3 = CurriculumLearningTrainer(
model=model_3,
dataset=train_dataset,
difficulty_scorer=difficulty_scorer_3,
pacing_function=pacing_function_3,
device=device,
scheduler_mode='hard'
)
print("Training without curriculum (baseline)...")
history_3 = trainer_3.train(
num_epochs=5,
batch_size=32,
learning_rate=0.001,
eval_dataset=eval_dataset,
eval_interval=1
)
print()
# Compare results
print("=" * 80)
print("COMPARISON OF RESULTS")
print("=" * 80)
print()
print("Final Training Accuracy:")
print(f" Config 1 (Heuristic + Fixed): {history_1['train_accuracy'][-1]:.4f}")
print(f" Config 2 (Adaptive + Self-Paced): {history_2['train_accuracy'][-1]:.4f}")
print(f" Config 3 (Baseline): {history_3['train_accuracy'][-1]:.4f}")
print()
print("Final Evaluation Accuracy:")
print(f" Config 1 (Heuristic + Fixed): {history_1['eval_accuracy'][-1]:.4f}")
print(f" Config 2 (Adaptive + Self-Paced): {history_2['eval_accuracy'][-1]:.4f}")
print(f" Config 3 (Baseline): {history_3['eval_accuracy'][-1]:.4f}")
print()
print("Training completed successfully!")
print("=" * 80)
if __name__ == "__main__":
main()
This complete implementation provides a production-ready curriculum learning system for text classification. The code includes multiple difficulty scoring strategies, both fixed and adaptive pacing functions, flexible scheduling modes, and a comprehensive training framework. The main function demonstrates three different configurations, allowing you to compare curriculum learning approaches against a baseline without curriculum learning.
The implementation follows clean code principles with clear separation of concerns. Each class has a single well-defined responsibility. The difficulty scorers are interchangeable, as are the pacing functions, allowing easy experimentation with different curriculum strategies. The code includes extensive documentation and handles edge cases properly.
You can adapt this implementation to other domains by replacing the TextDataset and TextClassifier with appropriate classes for your task. The curriculum learning components remain the same regardless of the specific machine learning problem you are solving.