Model Merging › Implementation

Michael BrenndoerferFebruary 28, 202613 min read
Language AI HandbookAll chapters
Part 41 of 67Model Compression
Chapter 311Model Merging › Implementation
Reading section 6 of 9 · Implementation · 13 minSection 6 of 9 · 13 min

Book contents

Section 6 of 9 · 13 min read

Model Merging › ImplementationOne section · Sections in this chapter6 / 9 ↓
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's implement these four merging methods from scratch using PyTorch. We will work with small toy models to keep the computation tractable, but the same code logic scales directly to full-size language models. The key operations, computing task vectors, applying masks, electing signs, and combining vectors, are all element-wise or reduction operations over flat parameter vectors that work identically regardless of model size.

Setup

We start by importing the necessary libraries and defining a simple linear model architecture that we can fine-tune on toy tasks. Using a small model lets us inspect the behavior of each method clearly without waiting for large-scale training.

In[3]:
Code
import numpy as np
import torch

torch.manual_seed(42)
np.random.seed(42)

Toy Model and Tasks

We define a small feedforward network and two simple regression tasks that require different parameter configurations. Each task is defined by a random projection of the input, so the two tasks require different linear functions of the same input space. A model that perfectly solves task A will not generally solve task B, making this a concrete test of each merging method's ability to combine capabilities.

In[4]:
Code
import torch.nn as nn


class ToyModel(nn.Module):
    def __init__(self, input_dim=16, hidden_dim=32, output_dim=1):
        super().__init__()
        self.fc1 = nn.Linear(input_dim, hidden_dim)
        self.act = nn.ReLU()
        self.fc2 = nn.Linear(hidden_dim, output_dim)

    def forward(self, x):
        return self.fc2(self.act(self.fc1(x)))


def make_task_data(n=200, input_dim=16, seed=0):
    rng = np.random.default_rng(seed)
    X = rng.standard_normal((n, input_dim)).astype(np.float32)
    # Task-specific random projection defines the target
    proj = rng.standard_normal(input_dim).astype(np.float32)
    y = (X @ proj).reshape(-1, 1)
    return torch.from_numpy(X), torch.from_numpy(y)


# Pretrained model: a random initialization representing a base model
pretrained_model = ToyModel()

# Task A and Task B data: different projections require different parameter configurations
X_a, y_a = make_task_data(seed=1)
X_b, y_b = make_task_data(seed=2)

Fine-Tuning Helper

A simple training loop fine-tunes the pretrained model on each task and returns the resulting fine-tuned model. The key point is that both fine-tunes start from the same pretrained weights, which is the essential condition for model merging to work well.

In[5]:
Code
import copy

import torch.optim as optim


def finetune(base_model, X, y, epochs=300, lr=1e-2):
    model = copy.deepcopy(base_model)
    optimizer = optim.Adam(model.parameters(), lr=lr)
    criterion = nn.MSELoss()
    for _ in range(epochs):
        optimizer.zero_grad()
        loss = criterion(model(X), y)
        loss.backward()
        optimizer.step()
    return model


model_a = finetune(pretrained_model, X_a, y_a)
model_b = finetune(pretrained_model, X_b, y_b)

Extracting Weights as Flat Vectors

All merging operations work on flat vectors of parameters. Converting model parameters to a flat vector is the first step in any merging pipeline. We define helpers to convert between flat vectors and model instances.

In[6]:
Code
def get_flat_weights(model):
    return torch.cat([p.detach().flatten() for p in model.parameters()])


def set_flat_weights(model, flat_weights):
    model_copy = copy.deepcopy(model)
    offset = 0
    with torch.no_grad():
        for p in model_copy.parameters():
            numel = p.numel()
            p.copy_(flat_weights[offset : offset + numel].reshape(p.shape))
            offset += numel
    return model_copy


theta_pretrained = get_flat_weights(pretrained_model)
theta_a = get_flat_weights(model_a)
theta_b = get_flat_weights(model_b)

The flat vector representation makes the merging code clean and general: all the operations work on 1D tensors regardless of the specific architecture. When scaling to real models, you would typically work with state dictionaries and apply operations layer by layer to avoid loading all parameters into memory simultaneously, but the logic is identical.

Weight Averaging

The simplest merge: compute the element-wise mean of all fine-tuned parameter vectors.

In[7]:
Code
def weight_average(pretrained_weights, finetuned_weights_list):
    stacked = torch.stack(finetuned_weights_list)
    return stacked.mean(dim=0)


theta_averaged = weight_average(theta_pretrained, [theta_a, theta_b])
model_averaged = set_flat_weights(pretrained_model, theta_averaged)

Task Arithmetic

Task arithmetic computes task vectors, scales them, and adds them back to the pretrained model. The scaling coefficient λ\lambda is the key hyperparameter controlling how much of the fine-tuned direction to incorporate.

In[8]:
Code
def task_arithmetic(pretrained_weights, finetuned_weights_list, lam=0.4):
    task_vectors = [fw - pretrained_weights for fw in finetuned_weights_list]
    summed_tv = torch.stack(task_vectors).sum(dim=0)
    return pretrained_weights + lam * summed_tv


theta_ta = task_arithmetic(theta_pretrained, [theta_a, theta_b], lam=0.4)
model_ta = set_flat_weights(pretrained_model, theta_ta)

TIES Merging

The TIES implementation follows the three steps: trim by magnitude percentile, elect sign by weighted majority vote, and disjoint merge using only agreeing entries.

In[9]:
Code
def ties_merge(
    pretrained_weights, finetuned_weights_list, lam=0.4, trim_pct=20.0
):
    task_vectors = [fw - pretrained_weights for fw in finetuned_weights_list]

    # Step 1: Trim - keep top trim_pct% by absolute magnitude
    trimmed = []
    for tv in task_vectors:
        threshold = torch.quantile(tv.abs(), 1.0 - trim_pct / 100.0)
        mask = tv.abs() >= threshold
        trimmed.append(tv * mask.float())

    # Step 2: Elect sign via magnitude-weighted majority vote
    stacked = torch.stack(trimmed)  # shape: (k, d)
    pos_magnitude = (stacked > 0).float() * stacked.abs()
    neg_magnitude = (stacked < 0).float() * stacked.abs()
    elected_sign = torch.sign(pos_magnitude.sum(0) - neg_magnitude.sum(0))
    elected_sign[elected_sign == 0] = 1.0  # break ties toward positive

    # Step 3: Disjoint merge - average only values agreeing with elected sign
    agree_mask = (stacked * elected_sign.unsqueeze(0)) > 0  # (k, d)
    masked_sum = (stacked * agree_mask.float()).sum(0)
    agree_count = agree_mask.float().sum(0).clamp(min=1.0)
    merged_tv = masked_sum / agree_count

    return pretrained_weights + lam * merged_tv


theta_ties = ties_merge(
    theta_pretrained, [theta_a, theta_b], lam=0.4, trim_pct=20.0
)
model_ties = set_flat_weights(pretrained_model, theta_ties)

Notice that agree_count.clamp(min=1.0) prevents division by zero in cases where no model has a nonzero value at some position after trimming. This can happen if all task vectors had small values at a particular position that were all trimmed away.

DARE Merging

DARE applies random masking with rescaling to each task vector independently, then combines with simple averaging.

In[10]:
Code
def dare_merge(
    pretrained_weights, finetuned_weights_list, lam=0.4, drop_rate=0.9, seed=42
):
    rng = torch.Generator()
    rng.manual_seed(seed)

    dare_task_vectors = []
    for i, fw in enumerate(finetuned_weights_list):
        tv = fw - pretrained_weights
        mask = (torch.rand(tv.shape, generator=rng) > drop_rate).float()
        scaled_tv = tv * mask / (1.0 - drop_rate)
        dare_task_vectors.append(scaled_tv)

    # After DARE preprocessing, merge with simple averaging
    summed = torch.stack(dare_task_vectors).mean(0)
    return pretrained_weights + lam * summed


theta_dare = dare_merge(
    theta_pretrained, [theta_a, theta_b], lam=0.4, drop_rate=0.9
)
model_dare = set_flat_weights(pretrained_model, theta_dare)

The rescaling by 1.0 - drop_rate ensures that the surviving entries carry the same expected magnitude as the original task vector, compensating for the entries that were dropped.

Evaluating All Methods

We evaluate each merged model on both tasks and compare their losses. This gives a concrete picture of how each method balances performance across multiple tasks.

In[11]:
Code
def eval_mse(model, X, y):
    with torch.no_grad():
        preds = model(X)
        return nn.MSELoss()(preds, y).item()


results = {
    "Pretrained": (
        eval_mse(pretrained_model, X_a, y_a),
        eval_mse(pretrained_model, X_b, y_b),
    ),
    "Fine-tuned A": (eval_mse(model_a, X_a, y_a), eval_mse(model_a, X_b, y_b)),
    "Fine-tuned B": (eval_mse(model_b, X_a, y_a), eval_mse(model_b, X_b, y_b)),
    "Avg": (
        eval_mse(model_averaged, X_a, y_a),
        eval_mse(model_averaged, X_b, y_b),
    ),
    "Task Arith": (eval_mse(model_ta, X_a, y_a), eval_mse(model_ta, X_b, y_b)),
    "TIES": (eval_mse(model_ties, X_a, y_a), eval_mse(model_ties, X_b, y_b)),
    "DARE": (eval_mse(model_dare, X_a, y_a), eval_mse(model_dare, X_b, y_b)),
}
Out[12]:
Console
Method            Task A MSE   Task B MSE      Avg MSE
-------------------------------------------------------
Pretrained           12.9741      15.2229      14.0985
Fine-tuned A          0.0008      45.2677      22.6343
Fine-tuned B         46.2103       0.0015      23.1059
Avg                  11.2517      11.6108      11.4312
Task Arith           11.3515      12.2900      11.8207
TIES                 11.3731      14.8002      13.0866
DARE                 14.8266      16.7858      15.8062

The results illustrate the core trade-off in model merging. Individual fine-tuned models excel on their own task but perform close to the pretrained baseline on the other task: they have specialized at the expense of generality. Each merging strategy balances performance across both tasks differently, with more sophisticated methods generally achieving better combined performance by reducing interference between task-specific updates. The pretrained model is a lower bound on merge quality: any merge that performs worse than the pretrained baseline on a task has failed its purpose.

Visualizing Task Vectors

The magnitude distribution of task vector entries reveals the core property that makes DARE and TIES effective: most fine-tuning changes are small and concentrated near zero, while only a minority of parameters receive large updates.

Out[13]:
Visualization
Two overlapping histograms of task vector entry magnitudes for Task A and Task B, with most values near zero and a long tail.
Magnitude distribution of task vector entries for Task A and Task B, shown as density histograms. The vast majority of entries are close to zero, forming a sharp spike at the left. A long tail of larger magnitudes represents the small fraction of parameters that were substantially updated during fine-tuning. TIES and DARE both exploit this structure, focusing the merge on the high-magnitude tail while discarding or randomizing the low-magnitude noise.

The distribution you see here is typical of fine-tuned models at any scale. The spike near zero represents parameters that fine-tuning barely touched. The long tail represents the relatively small number of parameters that were significantly adjusted to accommodate the task. Trimming in TIES targets this structure explicitly: it keeps the tail and discards the spike. DARE samples randomly from both regions, but because the spike contains far more parameters, most of what it keeps are parameters from the tail when the drop rate is high enough.

Merging Method Comparison

Out[14]:
Visualization
Horizontal bar chart comparing average MSE for seven models: pretrained, fine-tuned A, fine-tuned B, weight average, task arithmetic, TIES, and DARE.
Average MSE across both tasks for each merging strategy. Lower values indicate better combined performance. The pretrained model sets a reference, and individual fine-tuned models show their strong per-task performance alongside poor performance on the other task. Weight averaging and task arithmetic are simple baselines, while TIES and DARE address interference between task vectors and typically achieve lower average MSE.

Visualizing the Effect of Lambda

The scaling coefficient λ\lambda controls how aggressively task vectors are applied. Too small, and the merged model barely departs from the pretrained baseline. Too large, and the combined updates push the model into territory where neither task can perform well. Scanning across lambda values reveals the operating range and the optimal point for each method.

In[15]:
Code
lambdas = np.linspace(0.0, 1.0, 21)
lambda_results = {"Task Arith": {"a": [], "b": []}, "TIES": {"a": [], "b": []}}

for lam in lambdas:
    t = task_arithmetic(theta_pretrained, [theta_a, theta_b], lam=float(lam))
    m = set_flat_weights(pretrained_model, t)
    lambda_results["Task Arith"]["a"].append(eval_mse(m, X_a, y_a))
    lambda_results["Task Arith"]["b"].append(eval_mse(m, X_b, y_b))

    t = ties_merge(theta_pretrained, [theta_a, theta_b], lam=float(lam))
    m = set_flat_weights(pretrained_model, t)
    lambda_results["TIES"]["a"].append(eval_mse(m, X_a, y_a))
    lambda_results["TIES"]["b"].append(eval_mse(m, X_b, y_b))
Out[16]:
Visualization
Line chart with lambda on the x-axis and MSE on the y-axis showing four curves for Task Arithmetic Task A, Task Arithmetic Task B, TIES Task A, and TIES Task B.
MSE on Task A and Task B as the scaling coefficient lambda varies from 0 (pretrained model) to 1 (full task vector addition) for Task Arithmetic and TIES. Both methods improve as lambda increases from zero, then degrade at high lambda values where the combined updates push the model outside its effective operating region. TIES typically remains stable at higher lambda values because sign conflicts have been resolved before the merge, making it less sensitive to over-scaling.

The curves reveal that both Task Arithmetic and TIES initially improve as lambda grows from zero, with performance gains accelerating as the task vectors contribute more of their signal. However, at some threshold, further increases in lambda cause degradation because the combined updates push the model beyond the region where it performs well on both tasks simultaneously. TIES tends to be more stable at higher lambda values because sign conflicts have been resolved before the merge, reducing the chance that a large lambda amplifies destructive interference.

The practical takeaway is that lambda requires tuning. The optimal value depends on the number of tasks, the degree of overlap between task vectors, and the scale of the task vector magnitudes. A small validation set covering the tasks you care about is usually sufficient to find a good lambda with a simple grid search.

Visualizing DARE Drop Rate Sensitivity

In[17]:
Code
drop_rates = np.linspace(0.0, 0.98, 20)
dare_a_mses = []
dare_b_mses = []

for dr in drop_rates:
    t = dare_merge(
        theta_pretrained, [theta_a, theta_b], lam=0.4, drop_rate=float(dr)
    )
    m = set_flat_weights(pretrained_model, t)
    dare_a_mses.append(eval_mse(m, X_a, y_a))
    dare_b_mses.append(eval_mse(m, X_b, y_b))
Out[18]:
Visualization
Line chart with drop rate on the x-axis and MSE on the y-axis, showing DARE merged model MSE on Task A and Task B as drop rate increases.
DARE merge quality as a function of the drop rate, evaluated on Task A and Task B separately. Performance remains relatively stable from low drop rates up to around 0.90-0.95, validating the empirical observation that most task vector entries can be discarded without major performance loss. The performance degrades sharply as the drop rate approaches 1.0, where almost no task vector signal survives. The rescaling step compensates for the dropped entries throughout the stable region.

The plot confirms that performance is relatively stable across a wide range of drop rates before degrading sharply as the drop rate approaches 1.0. This plateau demonstrates why DARE can aggressively prune task vectors: most entries are redundant, and the rescaling step ensures the surviving entries carry the same expected signal as the full task vector. In practice, drop rates of 0.9 are standard, and rates as high as 0.95 often work well, particularly when combining DARE with TIES.