Jared AI Hub
Published on

Deep Learning Optimizers: A Comprehensive Guide to Training Neural Networks

Authors
  • avatar
    Name
    Jared Chung
    Twitter

Training deep neural networks is like navigating through a vast, complex landscape to find the lowest valley. Your neural network has millions of parameters, and you need to adjust each one to minimize your loss function. The optimizer is your navigation strategy - it determines how you move through this landscape.

Think of it like this: imagine you're hiking in thick fog, trying to reach the bottom of a valley. You can only see a few feet ahead (that's your gradient), and you need to decide which direction to step and how big steps to take. Different optimizers are like different hiking strategies.

Why Neural Network Optimization is Hard

The Challenge: A Mountainous Landscape in Millions of Dimensions

1. Non-convex Terrain

  • Unlike a simple bowl shape, neural network loss landscapes have many peaks, valleys, and plateaus
  • You might get stuck in a "local valley" that's not the deepest one
  • Think: hiking in mountainous terrain vs. walking down a simple hill

2. High-Dimensional Space

  • Modern networks have millions of parameters
  • Hard to visualize: imagine navigating not just north/south/east/west, but in millions of directions simultaneously

3. Noisy Information

  • We use mini-batches (small samples) instead of the full dataset
  • Like getting slightly different compass readings each time you check
  • The path might zigzag even when you're heading in the right general direction

4. Different Scales

  • Some parameters might need big adjustments, others tiny ones
  • Like needing different step sizes for different terrain

Understanding Gradient Descent: The Foundation

All optimizers build on this simple idea: move in the direction of steepest descent.

def simple_gradient_descent(current_position, gradient, learning_rate):
    """
    The core idea behind all optimizers:
    
    current_position: Where you are now
    gradient: Which direction is steepest downhill
    learning_rate: How big steps to take
    """
    
    # Move opposite to the gradient (downhill)
    new_position = current_position - learning_rate * gradient
    
    return new_position

# Example: Learning rate effect
gradients = [0.1, 0.1, 0.1]  # Consistent downhill direction

# Small learning rate: slow but steady
position_small = 1.0
for grad in gradients:
    position_small = simple_gradient_descent(position_small, grad, learning_rate=0.1)
    print(f"Small LR: {position_small:.2f}")

# Large learning rate: fast but might overshoot  
position_large = 1.0
for grad in gradients:
    position_large = simple_gradient_descent(position_large, grad, learning_rate=1.0)
    print(f"Large LR: {position_large:.2f}")

Stochastic Gradient Descent (SGD): The Basics

SGD is the simplest optimizer - it's like taking one step at a time based on your current view of the terrain:

class SimpleSGD:
    """
    Basic SGD: Move downhill based on current gradient
    
    Like a hiker who:
    1. Looks around to see which way is downhill
    2. Takes a step in that direction
    3. Repeats
    """
    def __init__(self, learning_rate=0.01):
        self.lr = learning_rate
    
    def update_parameter(self, current_value, gradient):
        # Simple rule: new_value = current_value - learning_rate * gradient
        return current_value - self.lr * gradient

# Example: Training a simple model
def train_with_sgd(model, data, target):
    optimizer = SimpleSGD(learning_rate=0.01)
    
    for epoch in range(100):
        # Forward pass: make prediction
        prediction = model(data)
        
        # Calculate how wrong we are
        loss = calculate_loss(prediction, target)
        
        # Get gradients (which way to adjust parameters)
        gradients = calculate_gradients(loss)
        
        # Update each parameter
        for param, grad in zip(model.parameters(), gradients):
            param = optimizer.update_parameter(param, grad)
        
        if epoch % 20 == 0:
            print(f"Epoch {epoch}, Loss: {loss:.4f}")

SGD Characteristics:

  • ✅ Simple and reliable
  • ✅ Works well with large datasets
  • ❌ Can be slow to converge
  • ❌ Might oscillate around the minimum
  • ❌ Same learning rate for all parameters

SGD with Momentum: Adding Memory

Basic SGD has a problem - it can get stuck oscillating or moving too slowly. Momentum fixes this by giving your optimizer "memory" of where it was going.

The Momentum Intuition

Think of momentum like a ball rolling down a hill:

  • Without momentum: The ball stops and starts with each push (gradient)
  • With momentum: The ball builds up speed, helping it roll through small bumps and keep moving toward the bottom
class SGDWithMomentum:
    """
    SGD with momentum: Like a ball rolling downhill
    
    The ball remembers its previous direction and speed,
    helping it:
    1. Move faster in consistent directions
    2. Smooth out noisy gradients 
    3. Push through small obstacles
    """
    def __init__(self, learning_rate=0.01, momentum=0.9):
        self.lr = learning_rate
        self.momentum = momentum
        self.velocity = 0  # How fast we're moving
    
    def update_parameter(self, current_value, gradient):
        # Update velocity: combine previous momentum with current gradient
        self.velocity = self.momentum * self.velocity + gradient
        
        # Update parameter using velocity (not just gradient)
        new_value = current_value - self.lr * self.velocity
        
        return new_value

# Example: How momentum helps
print("Without Momentum (SGD):")
position = 0
gradients = [1, -0.5, 1, -0.5, 1]  # Oscillating gradients
sgd = SimpleSGD(learning_rate=0.1)

for i, grad in enumerate(gradients):
    position = sgd.update_parameter(position, grad)
    print(f"Step {i}: Position = {position:.2f}")

print("\nWith Momentum:")
position = 0
sgd_momentum = SGDWithMomentum(learning_rate=0.1, momentum=0.9)

for i, grad in enumerate(gradients):
    position = sgd_momentum.update_parameter(position, grad)
    print(f"Step {i}: Position = {position:.2f}, Velocity = {sgd_momentum.velocity:.2f}")

Momentum Benefits:

  • ✅ Faster convergence in consistent directions
  • ✅ Reduced oscillation in noisy gradients
  • ✅ Can escape small local minima
  • ❌ Might overshoot the target
  • ❌ One more hyperparameter to tune

Nesterov Momentum: Looking Ahead

Regular momentum can overshoot. Nesterov momentum is smarter - it "looks ahead" before deciding where to go:

class NesterovMomentum:
    """
    Nesterov momentum: Look before you leap
    
    Like a smart hiker who:
    1. Takes a step in the direction they were going
    2. Looks around from that new position  
    3. Adjusts their next step based on what they see
    """
    def __init__(self, learning_rate=0.01, momentum=0.9):
        self.lr = learning_rate
        self.momentum = momentum
        self.velocity = 0
    
    def update_parameter(self, current_value, gradient_function):
        # Step 1: Look ahead using current velocity
        lookahead_position = current_value - self.momentum * self.velocity
        
        # Step 2: Calculate gradient at the lookahead position
        lookahead_gradient = gradient_function(lookahead_position)
        
        # Step 3: Update velocity based on lookahead gradient
        self.velocity = self.momentum * self.velocity + lookahead_gradient
        
        # Step 4: Take actual step
        new_value = current_value - self.lr * self.velocity
        
        return new_value

Why Nesterov Works Better:

  • Regular momentum: "I was going this way, so I'll keep going this way"
  • Nesterov momentum: "If I keep going this way, where will I end up? Is that still the right direction?"

Adaptive Learning Rates: Smart Step Sizes

The problem with SGD is that it uses the same learning rate for all parameters. But what if some parameters need big updates and others need tiny ones?

AdaGrad: Frequent Updates Get Smaller Steps

AdaGrad remembers how often each parameter has been updated and gives smaller learning rates to frequently updated parameters:

class AdaGradSimple:
    """
    AdaGrad: Give smaller learning rates to frequently updated parameters
    
    Like adjusting your hiking pace:
    - If you've been walking on flat ground (small gradients), take normal steps
    - If you've been climbing steep hills (large gradients), take smaller steps to be careful
    """
    def __init__(self, learning_rate=0.01):
        self.lr = learning_rate
        self.gradient_sum_squares = 0  # Track how much this parameter has been updated
    
    def update_parameter(self, current_value, gradient):
        # Accumulate squared gradients (bigger gradients = more frequent updates)
        self.gradient_sum_squares += gradient ** 2
        
        # Adaptive learning rate: smaller if gradients have been large
        adaptive_lr = self.lr / (self.gradient_sum_squares ** 0.5 + 1e-8)
        
        new_value = current_value - adaptive_lr * gradient
        
        return new_value

# Example: AdaGrad in action
print("AdaGrad with different gradient patterns:")

# Parameter that gets small, consistent gradients
param1 = 1.0
adagrad1 = AdaGradSimple(learning_rate=0.1)

# Parameter that gets large, infrequent gradients  
param2 = 1.0
adagrad2 = AdaGradSimple(learning_rate=0.1)

for step in range(5):
    # Consistent small gradients
    param1 = adagrad1.update_parameter(param1, gradient=0.1)
    
    # One large gradient, then small ones
    grad2 = 1.0 if step == 0 else 0.1
    param2 = adagrad2.update_parameter(param2, gradient=grad2)
    
    print(f"Step {step}: Param1={param1:.3f}, Param2={param2:.3f}")

AdaGrad Characteristics:

  • ✅ Automatically adapts learning rates per parameter
  • ✅ Works well for sparse data/parameters
  • ❌ Learning rate keeps decreasing and can become too small
  • ❌ Eventually stops learning entirely

RMSprop: Fixing AdaGrad's Decay Problem

AdaGrad has a fatal flaw - the learning rate only decreases, never increases. Eventually, it becomes so small that learning stops. RMSprop fixes this:

class RMSpropSimple:
    """
    RMSprop: Like AdaGrad but with a forgetting mechanism
    
    Instead of remembering ALL previous gradients forever,
    RMSprop uses a "moving average" that forgets old gradients.
    
    Like judging a student's performance based on recent tests
    rather than their entire academic history.
    """
    def __init__(self, learning_rate=0.01, decay_rate=0.9):
        self.lr = learning_rate
        self.decay_rate = decay_rate
        self.gradient_moving_avg = 0
    
    def update_parameter(self, current_value, gradient):
        # Moving average of squared gradients (forgets old gradients)
        self.gradient_moving_avg = (self.decay_rate * self.gradient_moving_avg + 
                                   (1 - self.decay_rate) * gradient ** 2)
        
        # Adaptive learning rate based on recent gradient history
        adaptive_lr = self.lr / (self.gradient_moving_avg ** 0.5 + 1e-8)
        
        new_value = current_value - adaptive_lr * gradient
        
        return new_value

RMSprop Benefits:

  • ✅ Adaptive learning rates per parameter
  • ✅ Doesn't suffer from vanishing learning rates
  • ✅ Works well in practice
  • ❌ Still requires tuning learning rate and decay rate

Adam: The Best of Both Worlds

Adam (Adaptive Moment Estimation) is like combining the best ideas from momentum and RMSprop. It's become the most popular optimizer because it often "just works" with minimal tuning.

The Adam Intuition

Think of Adam as a sophisticated hiker who:

  1. Remembers momentum (like SGD with momentum)
  2. Adapts step size (like RMSprop)
  3. Corrects for bias when starting out
class AdamSimple:
    """
    Adam: Momentum + Adaptive Learning Rates + Bias Correction
    
    Like a smart hiker who:
    1. Builds momentum in consistent directions (first moment)
    2. Takes smaller steps in frequently traveled areas (second moment)
    3. Accounts for early-stage bias in their estimates
    """
    def __init__(self, learning_rate=0.001, beta1=0.9, beta2=0.999):
        self.lr = learning_rate
        self.beta1 = beta1  # momentum decay rate
        self.beta2 = beta2  # RMSprop decay rate
        
        # State variables
        self.momentum = 0      # first moment (like momentum)
        self.velocity = 0      # second moment (like RMSprop)
        self.step_count = 0    # for bias correction
    
    def update_parameter(self, current_value, gradient):
        self.step_count += 1
        
        # Update momentum (first moment) - like SGD momentum
        self.momentum = self.beta1 * self.momentum + (1 - self.beta1) * gradient
        
        # Update velocity (second moment) - like RMSprop
        self.velocity = self.beta2 * self.velocity + (1 - self.beta2) * gradient**2
        
        # Bias correction - important in early steps
        momentum_corrected = self.momentum / (1 - self.beta1**self.step_count)
        velocity_corrected = self.velocity / (1 - self.beta2**self.step_count)
        
        # Update parameter
        adaptive_lr = self.lr / (velocity_corrected**0.5 + 1e-8)
        new_value = current_value - adaptive_lr * momentum_corrected
        
        return new_value

# Example: Why Adam works well
print("Adam in action:")
param = 1.0
adam = AdamSimple(learning_rate=0.1)

# Simulate different gradient patterns
gradient_patterns = [
    [0.1, 0.1, 0.1, 0.1, 0.1],  # Consistent gradients
    [1.0, 0.1, 0.1, 0.1, 0.1],  # One large, then small
    [0.1, -0.05, 0.1, -0.05, 0.1]  # Oscillating
]

for pattern_name, gradients in zip(['Consistent', 'Spike then small', 'Oscillating'], gradient_patterns):
    print(f"\n{pattern_name} gradients:")
    param = 1.0
    adam = AdamSimple(learning_rate=0.1)
    
    for i, grad in enumerate(gradients):
        param = adam.update_parameter(param, grad)
        print(f"  Step {i}: param={param:.3f}, momentum={adam.momentum:.3f}, velocity={adam.velocity:.3f}")

Why Adam Works So Well

1. Momentum Benefits: Builds speed in consistent directions, smooths out noise

2. Adaptive Learning Rates: Automatically adjusts step size per parameter

3. Bias Correction: Prevents early steps from being too conservative

4. Robust Defaults: The default parameters (β₁=0.9, β₂=0.999, lr=0.001) work well for most problems

Adam Characteristics:

  • ✅ Often works out-of-the-box with minimal tuning
  • ✅ Combines benefits of momentum and adaptive learning rates
  • ✅ Good for most deep learning tasks
  • ✅ Handles sparse gradients well
  • ❌ Can sometimes converge to worse solutions than SGD
  • ❌ Might need learning rate decay for best results

When to Use Adam vs SGD

Use Adam when:

  • Starting a new project (good default choice)
  • Working with sparse data
  • Want minimal hyperparameter tuning
  • Training transformers or modern architectures

Use SGD when:

  • Final fine-tuning for best performance
  • Working on computer vision tasks (CNNs)
  • Need the most robust convergence
  • Willing to tune learning rate schedule

Modern Optimizer Variations

AdamW: Fixing Weight Decay

Regular Adam has a subtle problem with weight decay (regularization). AdamW fixes this:

class AdamWSimple:
    """
    AdamW: Adam with proper weight decay
    
    The key insight: weight decay should be separate from gradient-based updates
    
    Regular Adam: Applies weight decay through gradients
    AdamW: Applies weight decay directly to parameters
    """
    def __init__(self, learning_rate=0.001, weight_decay=0.01):
        self.lr = learning_rate
        self.weight_decay = weight_decay
        # ... (same Adam setup as before)
    
    def update_parameter(self, current_value, gradient):
        # Step 1: Apply weight decay directly (this is the key difference)
        current_value = current_value * (1 - self.lr * self.weight_decay)
        
        # Step 2: Apply Adam updates as normal
        # ... (same Adam logic as before)
        
        return new_value

Why AdamW Matters:

  • ✅ Better regularization behavior
  • ✅ More stable training for transformers
  • ✅ Cleaner separation of optimization and regularization
  • Most modern papers use AdamW instead of Adam

Other Notable Optimizers

RAdam (Rectified Adam): Fixes early training instability in Adam

  • Problem: Adam can behave badly in the first few steps
  • Solution: Use SGD-like updates until Adam's statistics are reliable

Lookahead: Wraps around other optimizers to stabilize training

  • Idea: Take several "fast" steps, then one "slow" step in the averaged direction
  • Works with any base optimizer (SGD, Adam, etc.)

Lion: Recently proposed, claims to be more memory-efficient than Adam

  • Uses only the sign of gradients, not their magnitude
  • Still being evaluated by the community

Choosing the Right Optimizer: A Practical Guide

The Quick Decision Tree

🚀 Starting a new project?
   └─ Use Adam or AdamW (they "just work")

🔬 Doing research/experimentation?
   └─ Use Adam for quick iterations
   
🏆 Want maximum performance?
   └─ Start with Adam, then try SGD with momentum for final training
   
🖼️ Training vision models (CNNs)?
   └─ SGD with momentum often works best
   
🤖 Training language models?
   └─ AdamW is the standard choice
   
⚡ Need fast convergence?
   └─ Adam family (Adam, AdamW, RAdam)
   
🎯 Need best final accuracy?
   └─ SGD with momentum + learning rate schedule

Optimizer Characteristics Summary

OptimizerSpeedFinal PerformanceTuning RequiredBest For
SGD⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐Computer Vision
SGD + Momentum⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐Final fine-tuning
Adam⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐General purpose
AdamW⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐Transformers
RMSprop⭐⭐⭐⭐⭐⭐⭐⭐⭐RNNs

Common Hyperparameter Settings

# Standard settings that work well in practice

# For most projects (start here)
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

# For transformers and modern NLP
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0001, weight_decay=0.01)

# For computer vision (CNNs)
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4)

# For RNNs
optimizer = torch.optim.RMSprop(model.parameters(), lr=0.001, momentum=0.9)

Learning Rate Scheduling: Fine-Tuning Your Optimizer

Even the best optimizer needs the right learning rate schedule. Think of it like adjusting your hiking pace based on the terrain:

Common Learning Rate Strategies

1. Step Decay: Reduce learning rate at specific milestones

# Example: Reduce LR by 50% every 10 epochs
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5)

# Usage during training
for epoch in range(100):
    train_one_epoch()
    scheduler.step()  # Update learning rate

2. Cosine Annealing: Smooth reduction following a cosine curve

# Smoothly reduce LR from initial to near zero over 100 epochs
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100)

3. Reduce on Plateau: Reduce when progress stalls

# Reduce LR when validation loss stops improving
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience=5)

# Usage
for epoch in range(100):
    train_loss = train_one_epoch()
    val_loss = validate()
    scheduler.step(val_loss)  # Pass the metric to monitor

Learning Rate Warmup

For large models, start with a small learning rate and gradually increase:

def get_warmup_schedule(optimizer, warmup_steps, total_steps):
    """
    Warmup: Start small, gradually increase to target LR
    
    Why this helps:
    - Large models can be unstable with large initial LR
    - Warmup gives the model time to "settle in"
    - Common in transformer training
    """
    def lr_schedule(step):
        if step < warmup_steps:
            # Linear warmup
            return step / warmup_steps
        else:
            # Cosine decay after warmup
            progress = (step - warmup_steps) / (total_steps - warmup_steps)
            return 0.5 * (1 + math.cos(math.pi * progress))
    
    return torch.optim.lr_scheduler.LambdaLR(optimizer, lr_schedule)

Advanced Training Techniques

Gradient Clipping: Preventing Explosions

Sometimes gradients become too large and destabilize training. Gradient clipping is like putting a speed limit on updates:

# Before taking the optimizer step, clip gradients
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

# In context:
for batch in dataloader:
    optimizer.zero_grad()
    loss = model(batch)
    loss.backward()
    
    # Clip gradients to prevent explosion
    torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
    
    optimizer.step()

Learning Rate Warmup: Starting Gentle

For large models, start with a tiny learning rate and gradually increase:

# Warmup schedule: start at 0, gradually increase to target LR
def get_warmup_schedule(optimizer, warmup_steps):
    def lr_schedule(step):
        if step < warmup_steps:
            return step / warmup_steps  # Linear increase
        else:
            return 1.0  # Full learning rate
    
    return torch.optim.lr_scheduler.LambdaLR(optimizer, lr_schedule)

Quick Troubleshooting Guide

Common Problems and Solutions

Problem: Loss explodes or goes to NaN

  • Solution: Reduce learning rate or add gradient clipping
  • Quick fix: torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)

Problem: Training is very slow

  • Solution: Increase learning rate or use a faster optimizer
  • Quick fix: Try Adam instead of SGD

Problem: Model trains but doesn't generalize well

  • Solution: Add weight decay or reduce learning rate
  • Quick fix: Use AdamW with weight_decay=0.01

Problem: Training plateaus early

  • Solution: Use learning rate scheduling
  • Quick fix: Add ReduceLROnPlateau scheduler

Simple Training Template

# A simple, robust training setup that works for most problems
def train_model_simple(model, train_loader, val_loader, num_epochs=100):
    # Use AdamW as default - works well for most cases
    optimizer = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=0.01)
    
    # Reduce LR when validation loss plateaus
    scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience=5)
    
    criterion = torch.nn.CrossEntropyLoss()
    best_val_loss = float('inf')
    
    for epoch in range(num_epochs):
        # Training phase
        model.train()
        train_loss = 0
        
        for batch in train_loader:
            optimizer.zero_grad()
            loss = criterion(model(batch['inputs']), batch['targets'])
            loss.backward()
            
            # Prevent gradient explosion
            torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
            
            optimizer.step()
            train_loss += loss.item()
        
        # Validation phase
        model.eval()
        val_loss = 0
        with torch.no_grad():
            for batch in val_loader:
                loss = criterion(model(batch['inputs']), batch['targets'])
                val_loss += loss.item()
        
        avg_val_loss = val_loss / len(val_loader)
        
        # Update learning rate based on validation performance
        scheduler.step(avg_val_loss)
        
        # Save best model
        if avg_val_loss < best_val_loss:
            best_val_loss = avg_val_loss
            torch.save(model.state_dict(), 'best_model.pth')
        
        print(f'Epoch {epoch}: Train Loss: {train_loss/len(train_loader):.4f}, '
              f'Val Loss: {avg_val_loss:.4f}, LR: {optimizer.param_groups[0]["lr"]:.2e}')

Conclusion: Your Optimizer Journey

Think of optimizers as different hiking strategies for navigating the loss landscape. Each has its strengths:

The Journey from Beginner to Expert

🚀 Beginner (Start Here)

  • Use Adam or AdamW with default settings
  • They "just work" for most problems
  • Focus on getting your model training first

🔬 Intermediate (Experiment)

  • Try different optimizers for your specific domain
  • Learn to tune learning rates and schedules
  • Understand when each optimizer works best

🏆 Expert (Optimize)

  • Use SGD with momentum for final fine-tuning
  • Implement custom learning rate schedules
  • Choose optimizers based on theoretical understanding

Key Takeaways for Learning

Remember the Hiking Analogy:

  • SGD: Steady, reliable hiker who gets to the best spots eventually
  • Momentum: Same hiker but with a rolling ball for speed
  • Adam: Smart hiker with adaptive gear who works in most conditions
  • AdamW: Smart hiker with better weight management

Practical Rules:

  1. Start with AdamW (lr=0.001, weight_decay=0.01)
  2. Add learning rate scheduling (ReduceLROnPlateau is safe)
  3. Use gradient clipping (max_norm=1.0) to prevent explosions
  4. Monitor training and adjust based on what you see

When Things Go Wrong:

  • Loss explodes → Lower learning rate or add gradient clipping
  • Training too slow → Try higher learning rate or Adam
  • Poor generalization → Add weight decay or use AdamW
  • Plateaus early → Add learning rate scheduling

The Bigger Picture

Optimizers are just one piece of successful deep learning. They work best when combined with:

  • Good data preprocessing
  • Appropriate model architecture
  • Proper regularization
  • Sufficient training data

The field keeps evolving, but understanding these fundamentals will serve you well whether you're using today's optimizers or tomorrow's innovations.

Start simple, experiment thoughtfully, and remember: the best optimizer is the one that gets your specific model to solve your specific problem effectively.

References

  • Kingma, D. P., & Ba, J. (2014). "Adam: A method for stochastic optimization."
  • Loshchilov, I., & Hutter, F. (2017). "Decoupled weight decay regularization."
  • Sutskever, I., et al. (2013). "On the importance of initialization and momentum in deep learning."
  • Smith, L. N. (2017). "Cyclical learning rates for training neural networks."