Pruning Basics › Code Implementation

Michael BrenndoerferFebruary 27, 202611 min read
Language AI HandbookAll chapters
Part 41 of 67Model Compression
Chapter 309Pruning Basics › Code Implementation
Reading section 7 of 11 · Code Implementation · 11 minSection 7 of 11 · 11 min

Book contents

Section 7 of 11 · 11 min read

Pruning Basics › Code ImplementationOne section · Sections in this chapter7 / 11 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let us implement the key components of a pruning pipeline using PyTorch's built-in pruning utilities. We will apply magnitude pruning to a small feedforward network and examine the sparsity-accuracy tradeoff as we increase the pruning ratio.

Setup and Network Definition

We start by importing the libraries and defining a small feedforward network for text classification.

In[5]:
Code
# Reproducibility
torch.manual_seed(42)
np.random.seed(42)


# Small feedforward network for binary text classification
class TextClassifier(nn.Module):
    def __init__(self, input_dim=100, hidden_dim=256, output_dim=2):
        super().__init__()
        self.fc1 = nn.Linear(input_dim, hidden_dim)
        self.relu = nn.ReLU()
        self.dropout = nn.Dropout(0.3)
        self.fc2 = nn.Linear(hidden_dim, hidden_dim // 2)
        self.fc3 = nn.Linear(hidden_dim // 2, output_dim)

    def forward(self, x):
        x = self.dropout(self.relu(self.fc1(x)))
        x = self.relu(self.fc2(x))
        return self.fc3(x)


model = TextClassifier(input_dim=100, hidden_dim=256, output_dim=2)
Out[6]:
Console
Total parameters: 59,010
  fc1.weight: torch.Size([256, 100]) -> 25,600 params
  fc1.bias: torch.Size([256]) -> 256 params
  fc2.weight: torch.Size([128, 256]) -> 32,768 params
  fc2.bias: torch.Size([128]) -> 128 params
  fc3.weight: torch.Size([2, 128]) -> 256 params
  fc3.bias: torch.Size([2]) -> 2 params

The model has three fully connected layers. The first layer maps 100-dimensional TF-IDF-style features to 256 hidden units, the second halves that to 128, and the third produces the two-class logits. With three layers of sizes 100x256, 256x128, and 128x2, the weight matrices contain 25,600, 32,768, and 256 parameters respectively. The first two layers together account for over 98% of the model's parameters and are the primary targets for pruning.

Synthetic Data and Training

We generate synthetic TF-IDF-like features and train the model to convergence before pruning. The synthetic task is simple: predict which of two word groups has a higher aggregate frequency in the input. This gives us a well-defined signal-to-noise ratio that makes the effect of pruning easy to interpret.

In[7]:
Code
# Synthetic TF-IDF-like data: sparse non-negative features
N_TRAIN, N_TEST = 2000, 500
INPUT_DIM = 100

X_train = torch.relu(torch.randn(N_TRAIN, INPUT_DIM))
y_train = (X_train[:, :10].sum(dim=1) > X_train[:, 10:20].sum(dim=1)).long()

X_test = torch.relu(torch.randn(N_TEST, INPUT_DIM))
y_test = (X_test[:, :10].sum(dim=1) > X_test[:, 10:20].sum(dim=1)).long()

train_dataset = TensorDataset(X_train, y_train)
test_dataset = TensorDataset(X_test, y_test)

train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False)
In[8]:
Code
def train_model(model, train_loader, epochs=20, lr=1e-3):
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    criterion = nn.CrossEntropyLoss()
    model.train()
    losses = []
    for epoch in range(epochs):
        epoch_loss = 0.0
        for X_batch, y_batch in train_loader:
            optimizer.zero_grad()
            logits = model(X_batch)
            loss = criterion(logits, y_batch)
            loss.backward()
            optimizer.step()
            epoch_loss += loss.item() * X_batch.size(0)
        losses.append(epoch_loss / len(train_loader.dataset))
    return losses


def evaluate_model(model, data_loader):
    model.eval()
    correct = 0
    total = 0
    with torch.no_grad():
        for X_batch, y_batch in data_loader:
            preds = model(X_batch).argmax(dim=1)
            correct += (preds == y_batch).sum().item()
            total += y_batch.size(0)
    return correct / total


training_losses = train_model(model, train_loader, epochs=20)
baseline_accuracy = evaluate_model(model, test_loader)
Out[9]:
Console
Baseline test accuracy: 0.962
Final training loss: 0.0160

Applying Magnitude Pruning

PyTorch's torch.nn.utils.prune module provides a clean API for applying pruning masks. We apply global unstructured magnitude pruning, which considers all weights across all specified layers together and prunes the globally smallest weights regardless of which layer they are in.

The global_unstructured function assigns a pruning mask to each parameter: a binary tensor of the same shape as the weight matrix, where 1 means "keep" and 0 means "prune." The actual weight values are not immediately zeroed; instead, PyTorch registers a forward hook that applies the mask on each forward pass. The prune.remove call makes the pruning permanent by baking the zeros directly into the weight tensor and removing the hook.

In[10]:
Code
def apply_global_magnitude_pruning(model, sparsity, make_permanent=True):
    pruned_model = copy.deepcopy(model)
    parameters_to_prune = [
        (pruned_model.fc1, "weight"),
        (pruned_model.fc2, "weight"),
        (pruned_model.fc3, "weight"),
    ]
    prune.global_unstructured(
        parameters_to_prune,
        pruning_method=prune.L1Unstructured,
        amount=sparsity,
    )
    if make_permanent:
        # Remove the reparameterization while preserving the zeroed weights.
        for module, name in parameters_to_prune:
            prune.remove(module, name)
    return pruned_model


def make_pruning_permanent(model):
    """Bake active pruning masks into each linear layer's weight tensor."""
    for module in model.modules():
        if isinstance(module, nn.Linear) and hasattr(module, "weight_mask"):
            prune.remove(module, "weight")


def measure_sparsity(model):
    total_weights = 0
    zero_weights = 0
    for module in model.modules():
        if isinstance(module, nn.Linear):
            total_weights += module.weight.numel()
            zero_weights += (module.weight == 0).sum().item()
    return zero_weights / total_weights if total_weights > 0 else 0.0
In[11]:
Code
# Evaluate performance across a range of sparsity levels (no fine-tuning)
sparsity_levels = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95]
results = []

for target_sparsity in sparsity_levels:
    if target_sparsity == 0.0:
        pruned = copy.deepcopy(model)
    else:
        pruned = apply_global_magnitude_pruning(model, target_sparsity)
    actual_sparsity = measure_sparsity(pruned)
    accuracy = evaluate_model(pruned, test_loader)
    results.append(
        {
            "target_sparsity": target_sparsity,
            "actual_sparsity": actual_sparsity,
            "accuracy": accuracy,
        }
    )
Out[12]:
Console
  Sparsity    Actual Sparsity   Test Accuracy
----------------------------------------------
        0%              0.000           0.962
       10%              0.100           0.966
       20%              0.200           0.964
       30%              0.300           0.962
       40%              0.400           0.960
       50%              0.500           0.954
       60%              0.600           0.958
       70%              0.700           0.936
       80%              0.800           0.952
       90%              0.900           0.900
       95%              0.950           0.822

Notice how accuracy holds up well through moderate sparsity but degrades more sharply past 80 to 90%. This pattern is characteristic of magnitude pruning without any fine-tuning. The model tolerates removal of small weights easily, because those weights were contributing little to begin with. But eventually the weights that are "small" by magnitude are still doing meaningful work, and removing them produces noticeable accuracy drops. The sparsity-accuracy curve is not linear; it has a long flat region followed by a steep cliff.

Out[13]:
Visualization
Line plot of test accuracy versus sparsity level showing graceful degradation up to 60% then sharp drop.
Test accuracy as a function of weight sparsity for global magnitude pruning without fine-tuning. Accuracy stays near baseline through moderate sparsity (up to around 60%), then degrades steeply as the pruning ratio climbs past 80%, illustrating the non-linear relationship between sparsity and performance loss.

Fine-Tuning After Pruning

Fine-tuning the pruned model allows it to redistribute the workload among the remaining weights and recover lost accuracy. The surviving weights adjust their magnitudes to compensate for the information that was lost with the pruned weights. This is possible because neural networks are highly overdetermined: multiple weight configurations can encode the same function, and fine-tuning finds a new configuration within the constraints imposed by the sparse mask.

In[14]:
Code
# Prune to 70% and fine-tune
TARGET_SPARSITY = 0.70
pruned_finetuned = apply_global_magnitude_pruning(
    model, TARGET_SPARSITY, make_permanent=False
)
accuracy_before_ft = evaluate_model(pruned_finetuned, test_loader)

# Fine-tune with a reduced learning rate while the pruning masks stay active.
ft_losses = train_model(pruned_finetuned, train_loader, epochs=10, lr=5e-4)
make_pruning_permanent(pruned_finetuned)
accuracy_after_ft = evaluate_model(pruned_finetuned, test_loader)
actual_sparsity_ft = measure_sparsity(pruned_finetuned)
Out[15]:
Console
Target sparsity:             70%
Actual sparsity:             0.700
Accuracy before fine-tuning: 0.936
Accuracy after fine-tuning:  0.972
Accuracy recovery:           +0.036

Fine-tuning even for a modest number of steps substantially recovers the accuracy lost to pruning. The pruned model learns to compensate for the missing weights by adjusting the values of surviving connections. This confirms the practical importance of including a fine-tuning phase after pruning: without it, the accuracy-sparsity curve clips off early; with it, you can achieve much higher sparsity at the same accuracy level.

The weight magnitude distribution provides a useful window into what pruning does to the parameter distribution. Before pruning, weights from a converged model typically form a roughly bell-shaped distribution centered near zero with long tails. After pruning, the near-zero region is surgically emptied, leaving only the weights that survived the magnitude threshold. The remaining weights are those in the long tails of the original distribution.

Out[16]:
Visualization
Two overlapping histograms showing weight magnitude distributions before and after 70% pruning.
Distribution of weight magnitudes before and after 70% magnitude pruning. Before pruning, the histogram shows a bell-shaped distribution centered near zero with most weights having small magnitudes. After pruning, all weights below the magnitude threshold are removed, leaving only the tails of the original distribution.

Gradual Pruning Schedule

Now we implement a simplified gradual pruning schedule: instead of pruning everything at once, we prune incrementally during training. The model is re-initialized with random weights for this experiment so we can observe the full gradual pruning trajectory from scratch.

In[17]:
Code
def gradual_pruning_schedule(final_sparsity, n_steps, current_step):
    if current_step >= n_steps:
        return final_sparsity
    fraction = current_step / n_steps
    return final_sparsity * (1 - (1 - fraction) ** 3)


def train_with_gradual_pruning(
    model_init,
    train_loader,
    final_sparsity=0.70,
    n_prune_steps=10,
    total_epochs=30,
    lr=1e-3,
):
    model_gp = copy.deepcopy(model_init)
    for module in model_gp.modules():
        if isinstance(module, nn.Linear):
            nn.init.xavier_uniform_(module.weight)
            nn.init.zeros_(module.bias)

    optimizer = torch.optim.Adam(model_gp.parameters(), lr=lr)
    criterion = nn.CrossEntropyLoss()
    prune_interval = max(1, total_epochs // n_prune_steps)
    accuracies = []
    sparsities = []

    for epoch in range(1, total_epochs + 1):
        model_gp.train()
        for X_batch, y_batch in train_loader:
            optimizer.zero_grad()
            loss = criterion(model_gp(X_batch), y_batch)
            loss.backward()
            optimizer.step()

        prune_step = epoch // prune_interval
        target_s = gradual_pruning_schedule(
            final_sparsity, n_prune_steps, prune_step
        )
        if target_s > 0 and epoch % prune_interval == 0:
            params = [
                (model_gp.fc1, "weight"),
                (model_gp.fc2, "weight"),
                (model_gp.fc3, "weight"),
            ]
            current_s = measure_sparsity(model_gp)
            incremental_amount = (target_s - current_s) / (1 - current_s)
            if incremental_amount > 0:
                prune.global_unstructured(
                    params,
                    pruning_method=prune.L1Unstructured,
                    amount=incremental_amount,
                )

        acc = evaluate_model(model_gp, test_loader)
        sp = measure_sparsity(model_gp)
        accuracies.append(acc)
        sparsities.append(sp)

    make_pruning_permanent(model_gp)
    return model_gp, accuracies, sparsities


gradual_model, gp_accuracies, gp_sparsities = train_with_gradual_pruning(
    model, train_loader, final_sparsity=0.70, n_prune_steps=10, total_epochs=30
)
final_gp_accuracy = evaluate_model(gradual_model, test_loader)
final_gp_sparsity = measure_sparsity(gradual_model)
Out[18]:
Console
Gradual pruning - final sparsity: 0.700
Gradual pruning - final accuracy: 0.958
Baseline accuracy:                0.962
Accuracy gap from baseline:       0.004

The gradual pruning trajectory reveals a characteristic pattern: accuracy dips slightly each time a new pruning step is applied, then recovers as training continues before the next pruning step. The dips are small because each individual pruning step removes only a small fraction of the total weight budget, and the model adapts quickly.

Out[19]:
Visualization
Cubic curve of target sparsity versus pruning step showing rapid early growth tapering off.
Cubic polynomial pruning schedule showing how target sparsity increases over training steps. The schedule front-loads pruning so the model spends more training time at high sparsity levels, where fine-tuning has the most recovery to do.
Dual-axis plot showing accuracy and sparsity over 30 epochs with step-wise sparsity increases.
Test accuracy and sparsity trajectories during gradual pruning over 30 training epochs. Accuracy briefly dips at each pruning step and recovers between steps, while sparsity increases in discrete jumps toward the 70% target.

Key Parameters

The key parameters for weight pruning are:

  • amount (sparsity): The fraction of weights to prune (for example, 0.7 for 70% sparsity). Higher values compress more aggressively but risk greater accuracy loss. The optimal value depends on the model architecture, task, and available fine-tuning compute.
  • pruning_method: The criterion for selecting which weights to remove. L1Unstructured applies magnitude pruning; RandomUnstructured provides a random baseline; custom methods can implement gradient-based or activation-based criteria.
  • global_unstructured vs. per-layer: Global pruning considers all layers together and allocates sparsity based on magnitude across the entire network, naturally pruning sensitive layers less. Per-layer pruning applies a uniform sparsity ratio to each layer independently, which may under-prune critical layers and over-prune redundant ones.
  • Fine-tuning learning rate: After pruning, fine-tuning with a reduced learning rate (typically 10 to 50% of the original) prevents large weight updates that could disrupt the remaining sparse structure.
  • Gradual pruning interval: How often sparsity increases during gradual pruning. More frequent updates create a smoother sparsity trajectory; less frequent updates allow more recovery time between pruning steps.