Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Distillation trains a smaller student from teacher signal. Model merging starts later: you already have useful fine-tuned checkpoints and want one checkpoint without another gradient-based training run.
Picture your platform team serving three same-base 8B assistants: Code, Math, and Chat. Routing among them costs extra memory and makes rollback messier. The first merge writes successfully and reports an aggregate score of 87.2. Then the slice report arrives: code 92.4, math 82.1 against an 85.0 gate, and chat 87.0. Keeping one model would simplify routing, but this candidate stays blocked. The failed Math slice is the useful starting point: a file on disk proves construction, not retained behavior.
Before choosing a recipe, check whether coordinates still mean the same thing. If token ID 417 means return in one tokenizer but <fim_prefix> in another, averaging row 417 blends unrelated output symbols. Direct tensor interpolation needs compatible shapes and meanings, and embedding matrices plus language-model heads need an explicit tokenizer policy. Mergekit can build a union tokenizer and assign fallback embeddings for missing tokens, but that's an output-space choice that still needs evaluation.[1] Same-base checkpoints are the conservative starting point because their parameter coordinates share lineage. Matching shapes alone doesn't make a merge good.
Keep Code, Math, and Chat as our running trio. We'll compare several recipes, from a plain average to sparse, conflict-aware task-vector merging. First ask whether a straight path through weight space is even usable.
When averaging weights is worth testing
If someone told you to average the internal numbers of two trained neural networks, skepticism is correct. The useful question is narrower: do these nearby checkpoints share a low-loss region? Weight averaging is plausible in some fine-tuning settings, but two good endpoints don't certify the points between them.
Fine-tuning moves a checkpoint from a shared starting point through weight space. Linear mode connectivity asks whether the straight path between endpoints crosses a high-loss barrier.[2] Model Soups found useful averages in its evaluated vision and text-classification settings when models shared a pretrained initialization and differed in hyperparameters.[3] That supports testing nearby same-lineage candidates. It doesn't show that Code and Math share one basin.
Evaluate points on rather than assuming the midpoint is usable. Code and Math can each pass alone and still interfere along the path. Models with unrelated pretraining lineages are even poorer candidates because no common base preserved their parameter coordinates.
Now make the first prediction. If the Code and Math endpoints pass, does pass too? The screen below treats interpolation measurements as evidence. In a real run, losses and task_scores come from held-out evaluations; these values are a worked Code-to-Math path whose midpoint misses both gates.
1alphas = [0.00, 0.25, 0.50, 0.75, 1.00]
2losses = [0.18, 0.20, 0.61, 0.23, 0.19]
3task_scores = [0.88, 0.86, 0.70, 0.85, 0.89]
4
5max_accepted_loss = 0.30
6min_accepted_score = 0.84
7
8accepted = [
9 alpha
10 for alpha, loss, score in zip(alphas, losses, task_scores)
11 if loss <= max_accepted_loss and score >= min_accepted_score
12]
13
14print("accepted_alphas:", accepted)
15print("midpoint_passes:", 0.50 in accepted)1accepted_alphas: [0.0, 0.25, 0.75, 1.0]
2midpoint_passes: False
Same-base lineage: A shared base makes Code and Math worth testing together. It doesn't establish that either behavior survives. Per-task evaluation decides that.
The permutation invariance problem
Why can two networks that solve the same task disagree at nearly every coordinate? Neural networks can exhibit permutation invariance: under corresponding reordering of incoming and outgoing weights, hidden-unit permutations can preserve the function a network computes.[4] A slot number is an address, not a feature identity, so independently trained networks may be poorly aligned for naive averaging.
Git Re-Basin[5] searches for a permutation that aligns the hidden units of one model to the other before merging:
Use a tiny three-feature example to see the failure. Two checkpoints learn A, B, and C but store them in different slot orders. Here stores B, C, A. Git Re-Basin searches for a permutation that restores A, B, C before averaging.

Git Re-Basin uses permutation matching and reports merged independently trained MLP, CNN, and ResNet models in its studied settings, including zero-barrier linear mode connectivity for sufficiently wide ResNets on CIFAR-10.[5] Alignment fixes one kind of coordinate mismatch. It isn't evidence that an arbitrary pair of large language models can be repaired and merged, so same-base candidates plus downstream evaluation remain our LLM default.
Why does Git Re-Basin apply before averaging?
Answer
Two models can represent the same function with neurons in different orders. The permutation aligns corresponding neurons first, so averaging combines matching features instead of unrelated ones.
From simple averages to conflict-aware methods
The first screen asks whether coordinates line up. The next asks what to do when they do: average endpoints, add deltas, filter conflicts, or change interpolation geometry. Choose against source lineage, observed delta conflict, and the evaluations the output must pass.
| Method | Mechanism | Pros | Cons | Best For |
|---|---|---|---|---|
| Model Soups / Linear | Uniform or weighted averaging | Simple baseline | Interference between conflicting parameters | Nearby checkpoints with representative evaluation |
| Task Arithmetic | Weighted task vectors | Separate coefficients per delta | Coefficients don't guarantee separate capabilities survive | Same-base task-vector experiments |
| TIES-Merging (Trim, Elect Sign, Merge) | Trim and aggregate-sign filtering | Explicitly handles conflicting delta signs | Density and scale need tuning | Conflicting same-base task vectors |
| DARE (sparsify first) | Random dropping and rescaling of task vectors | Sparse preprocessing evaluated by its paper | Not a complete merge rule on its own | Testing DARE plus a downstream merge |
| SLERP (Spherical Linear Interpolation) | Spherical interpolation in direction space | Has a norm-preserving geometric interpretation | Geometry alone doesn't establish task quality | Pairwise interpolation experiment |
Model soups (uniform averaging)
Start with the deliberately boring control: average the endpoint weights and see what behavior you lose. This is Model Soups[3], applied to multiple fine-tuned models:
Predict the tiny example before reading the code. If checkpoint A has a weight 2.0 and checkpoint B has 4.0, equal averaging returns 3.0. The dictionaries below stand in for tensor slices, so the arithmetic is visible without loading a checkpoint. A single coordinate still can't tell you whether Code, Math, or Chat behavior survived.
Model Soups evaluates models fine-tuned from a shared initialization over hyperparameter configurations and reports improved accuracy and robustness in its studied settings without the inference cost of an ensemble.[3] That evidence motivates a baseline for nearby candidates, not a blanket rule for specialists.
The original paper distinguishes two selection strategies. Uniform soups average all fine-tuned checkpoints with equal weight, which is simple but risky: one bad checkpoint can drag the average down. Greedy soups start from the best individual model and add a candidate only if the resulting average improves a held-out validation metric. In the reported experiments, greedy soups outperform uniform averaging because the selection rule skips candidates that hurt that validation metric.[3] That rule is only as useful as the validation slices behind it.
uniform_merge averages matching keys. greedy_keep adds a candidate only when the trial soup score rises. Run it as a control before introducing task-vector filtering.
1def uniform_merge(
2 models: list[dict[str, list[float]]],
3 weights: list[float] | None = None,
4) -> dict[str, list[float]]:
5 if weights is None:
6 weights = [1.0 / len(models)] * len(models)
7 if abs(sum(weights) - 1.0) >= 1e-6:
8 raise ValueError("Weights must sum to 1")
9
10 merged: dict[str, list[float]] = {}
11 for key in models[0]:
12 merged[key] = [
13 sum(w * m[key][i] for w, m in zip(weights, models))
14 for i in range(len(models[0][key]))
15 ]
16 return merged
17
18def greedy_keep(names: list[str], trial_scores: list[float]) -> list[str]:
19 kept: list[str] = []
20 best = float("-inf")
21 for name, score in zip(names, trial_scores):
22 if score > best:
23 kept.append(name)
24 best = score
25 return kept
26
27code_model = {"w": [2.0, 4.0], "bias": [1.0]}
28math_model = {"w": [4.0, 2.0], "bias": [3.0]}
29merged = uniform_merge([code_model, math_model])
30kept = greedy_keep(["best", "X", "Y", "Z"], [88.0, 89.0, 87.0, 90.0])
31
32print("weights_ok:", merged["w"] == [3.0, 3.0])
33print("bias_ok:", merged["bias"] == [2.0])
34print("merged w:", merged["w"])
35print("merged bias:", merged["bias"])
36print("greedy kept:", kept)1weights_ok: True
2bias_ok: True
3merged w: [3.0, 3.0]
4merged bias: [2.0]
5greedy kept: ['best', 'X', 'Z']Averaging is cheap to implement and has no hyperparameters beyond optional weights. On our Code, Math, and Chat trio, a poor aggregate result tells you little about which task caused the loss. Conflicting parameters can cancel when specialists move in opposite directions. To keep those directions visible, the next recipe subtracts the shared base first.
Task arithmetic
The average baseline hides why specialists moved. Task arithmetic[6] keeps that reference point: a task vector is the difference between fine-tuned weights and the shared base model.
Here is the base model, are fine-tuned models, are per-task deltas, and are merge weights.
A concrete scalar walkthrough
Before you run this on billion-parameter tensors, make one prediction on a single coordinate. Start with a base weight at 2.0. Code pushes it to 2.5, so its task vector is +0.5; Math pushes it to 1.5, so its task vector is -0.5. If Code gets and Math gets , does the merged coordinate land above or below the base?
The result, 2.1, is a net positive nudge at this coordinate. Averaging the absolute fine-tuned weights (2.5 and 1.5) would land at 2.0, cancelling both deltas here. The scalar doesn't predict capability retention. It shows what coefficients do before you inspect billions of such coordinates and then test the model.

Why did absolute averaging cancel the two specialist deltas in the scalar example?
Answer
The code-generation fine-tune moved this coordinate up by +0.5 and the math-reasoning fine-tune moved it down by -0.5. Averaging final weights lands this coordinate back at the base value, cancelling both deltas here. Task evaluation determines the broader behavioral effect.
A second coefficient pair exposes a tuning detail. Suppose Code nudges the coordinate to 2.4 (task vector +0.4) and Math nudges it to 1.8 (task vector -0.2). Setting and gives 2.0 + 0.28 - 0.10 = 2.18. The coefficients need not form a convex average; they scale deltas. Capability retention still comes from evaluation.
task_arithmetic_merge subtracts the base to get task vectors, scales them, and adds them back:
1def task_arithmetic_merge(
2 base_model: dict[str, list[float]],
3 fine_tuned_models: list[dict[str, list[float]]],
4 scaling_coefficients: list[float],
5) -> dict[str, list[float]]:
6 merged = {key: value[:] for key, value in base_model.items()}
7 for model, coeff in zip(fine_tuned_models, scaling_coefficients):
8 for key in merged:
9 merged[key] = [
10 merged_value + coeff * (tuned - base)
11 for merged_value, tuned, base in zip(
12 merged[key], model[key], base_model[key]
13 )
14 ]
15 return merged
16
17shared_base = {"w": [2.0]}
18code_ft = {"w": [2.5]}
19math_ft = {"w": [1.5]}
20merged = task_arithmetic_merge(
21 base_model=shared_base,
22 fine_tuned_models=[code_ft, math_ft],
23 scaling_coefficients=[0.6, 0.4],
24)
25
26print("base:", shared_base["w"][0])
27print("matches_expected:", all(abs(v - expected) < 1e-6 for v, expected in zip(merged["w"], [2.1])))
28print("merged:", round(merged["w"][0], 2))1base: 2.0
2matches_expected: True
3merged: 2.1Scaling coefficients control how much of each task vector enters the candidate checkpoint. They don't resolve a coordinate where Code and Math ask for opposite directions. That conflict is the reason to move from task arithmetic to TIES.
TIES-Merging (trim, elect sign, merge)
Task arithmetic exposes conflict but doesn't resolve it. One specialist can increase a coordinate by 0.5 while another decreases it by 0.5. TIES-Merging[7] turns that conflict into a three-step filter: trim small deltas, elect a sign from total signed movement, then average only values aligned with that sign.
The paper's PEFT analysis found that keeping the top 20% of magnitudes often matched keeping all values on its studied tasks.[7] Treat that result as evidence for its settings. Trim is a noise filter, not a guaranteed compressor for a new LLM merge.
Use the three specialist rows in the figure as a prediction exercise. At p2, the two negative entries total -0.90, while the positive entry is +1.10. Which sign wins? Positive signed mass wins, and only the aligned +1.10 survives the disjoint merge. The resulting task vector still gets added back to the base, often with a global scale .

Step 1: Trim
Trim keeps the largest-magnitude fraction of each task vector. With four values and density=0.5, predict which two survive, then check the code: they are the two biggest absolute updates.
1def trim(task_vector: list[float], density: float = 0.2) -> list[float]:
2 k = max(1, round(len(task_vector) * density))
3 keep = set(
4 sorted(range(len(task_vector)), key=lambda i: abs(task_vector[i]), reverse=True)[:k]
5 )
6 return [value if i in keep else 0.0 for i, value in enumerate(task_vector)]
7
8task_vector = [0.1, -0.5, 0.02, 0.8]
9trimmed = trim(task_vector, density=0.5)
10
11print("matches_expected:", trimmed == [0.0, -0.5, 0.0, 0.8])
12print("trimmed:", trimmed)1matches_expected: True
2trimmed: [0.0, -0.5, 0.0, 0.8]Step 2: Elect sign
When multiple task vectors update one parameter, TIES-Merging resolves conflicting directions with .[7] Sum the trimmed signed deltas and take the resulting sign. This isn't a majority vote: one large update can outweigh two smaller opposing updates.
1def sign(value: float) -> float:
2 if value > 0:
3 return 1.0
4 if value < 0:
5 return -1.0
6 return 0.0
7
8def elect_sign(trimmed_vectors: list[list[float]]) -> list[float]:
9 width = len(trimmed_vectors[0])
10 aggregate = [sum(vector[i] for vector in trimmed_vectors) for i in range(width)]
11 return [sign(total) for total in aggregate]
12
13trimmed_vectors = [
14 [0.0, -0.5, 0.0, 0.8],
15 [0.0, 1.1, 0.0, -0.6],
16 [0.0, -0.4, 0.0, 0.7],
17]
18elected = elect_sign(trimmed_vectors)
19print("two_negative_votes_at_p2:", True)
20print("positive_mass_wins_at_p2:", elected[1] == 1.0)
21print("matches_expected:", elected == [0.0, 1.0, 0.0, 1.0])
22print("elected signs:", elected)1two_negative_votes_at_p2: True
2positive_mass_wins_at_p2: True
3matches_expected: True
4elected signs: [0.0, 1.0, 0.0, 1.0]Step 3: Disjoint merge
Now average only non-zero updates that agree with the elected sign. Trimmed zeros stay out of the mean, so a discarded proposal can't dilute the surviving direction:
1def sign(value: float) -> float:
2 if value > 0:
3 return 1.0
4 if value < 0:
5 return -1.0
6 return 0.0
7
8def disjoint_merge(
9 trimmed_vectors: list[list[float]],
10 elected_signs: list[float],
11) -> list[float]:
12 width = len(trimmed_vectors[0])
13 merged = [0.0] * width
14 counts = [0.0] * width
15 for vector in trimmed_vectors:
16 for i, value in enumerate(vector):
17 agrees = (
18 value != 0.0
19 and elected_signs[i] != 0.0
20 and sign(value) == elected_signs[i]
21 )
22 if agrees:
23 merged[i] += value
24 counts[i] += 1.0
25 return [
26 total / count if count > 0 else 0.0
27 for total, count in zip(merged, counts)
28 ]
29
30trimmed_vectors = [
31 [0.0, -0.5, 0.0, 0.8],
32 [0.0, 1.1, 0.0, -0.6],
33 [0.0, -0.4, 0.0, 0.7],
34]
35elected_signs = [0.0, 1.0, 0.0, 1.0]
36merged = disjoint_merge(trimmed_vectors, elected_signs)
37
38print("matches_expected:", merged == [0.0, 1.1, 0.0, 0.75])
39print("merged task vector:", merged)1matches_expected: True
2merged task vector: [0.0, 1.1, 0.0, 0.75]TIES-Merging outperforms compared baselines in the paper's evaluated vision and T5 task-vector settings, and its analysis identifies sign interference.[7] For our LLM trio, use it as a candidate when delta signs conflict. Keep plain task arithmetic in the comparison so any gain has a visible baseline.
DARE (drop and rescale)[8]
TIES chooses a direction among surviving updates. DARE asks a different question first: how many update entries need to survive at all? It randomly drops entries from each task vector and rescales the survivors before a downstream merge. Rescaling preserves an entry's expected delta under the random mask; it doesn't prove that the model preserves a capability.
The paper studies redundancy in supervised fine-tuning (SFT) deltas and reports that its evaluated models can often tolerate dropping 90% of delta entries, and in some cases 99%, before merging. Its size ablation reports that WizardMath-70B remains effective at a 99% drop rate while the evaluated 7B and 13B variants fail there.[8] Treat that as evidence for the paper's SFT models, not as our trio's default density. DARE is a sparsification step, not a complete merge recipe: sparse task vectors still need a merger such as averaging or TIES.
DARE's analysis separates SFT deltas, which it observes are typically within roughly 0.002, from continued-pretraining deltas that approach 0.03; its drop-and-rescale approach becomes ineffective on the latter.[8] One candidate pipeline is DARE-TIES: DARE sparsifies each delta, then TIES resolves directional conflicts among survivors. Mergekit exposes that composition as dare_ties.[1]
where is a random binary mask with drop rate and the factor preserves each entry's expectation under masking.[8]
Why does DARE divide by after dropping weights?
Answer
Only a fraction of task-vector entries survive. Dividing by keeps each randomly masked delta entry unbiased in expectation; retained task quality still requires evaluation.
dare_sparsify applies a keep-mask and the rescale. DARE chooses its mask randomly; this example fixes the mask so you can check the arithmetic. With , the last two entries survive and double: 0.1 becomes 0.2 and 0.6 becomes 1.2. Predict those values before running it.
1def dare_sparsify(
2 task_vector: list[float],
3 drop_rate: float,
4 keep_mask: list[int],
5) -> list[float]:
6 keep_prob = 1.0 - drop_rate
7 if not 0.0 < keep_prob <= 1.0:
8 raise ValueError("drop_rate must be in [0, 1)")
9 if len(keep_mask) != len(task_vector):
10 raise ValueError("keep_mask must match task_vector")
11 return [
12 value / keep_prob if kept else 0.0
13 for value, kept in zip(task_vector, keep_mask)
14 ]
15
16task_vector = [0.2, -0.4, 0.1, 0.6]
17sparsified = dare_sparsify(task_vector, drop_rate=0.5, keep_mask=[0, 0, 1, 1])
18invalid_drop_rate_rejected = False
19try:
20 dare_sparsify(task_vector, drop_rate=1.0, keep_mask=[1, 1, 1, 1])
21except ValueError as exc:
22 invalid_drop_rate_rejected = "drop_rate" in str(exc)
23
24print("shape_ok:", len(sparsified) == len(task_vector))
25print("finite_ok:", all(value == value and abs(value) != float("inf") for value in sparsified))
26print("matches_expected:", sparsified == [0.0, 0.0, 0.2, 1.2])
27print("invalid_drop_rate_rejected:", invalid_drop_rate_rejected)
28print("original nonzero:", sum(value != 0.0 for value in task_vector))
29print("sparsified nonzero:", sum(value != 0.0 for value in sparsified))
30print("sparsified:", sparsified)1shape_ok: True
2finite_ok: True
3matches_expected: True
4invalid_drop_rate_rejected: True
5original nonzero: 4
6sparsified nonzero: 2
7sparsified: [0.0, 0.0, 0.2, 1.2]On the SFT models and tasks it evaluates, DARE finds substantial redundancy in task-vector entries and improves several downstream merge methods after drop-and-rescale preprocessing.[8] For a new checkpoint family, density remains a tuned parameter. Compare unsparsified and DARE-preprocessed candidates on every required task slice before deciding whether sparsity helped.
SLERP (spherical linear interpolation)
SLERP changes the path geometry, not the evidence standard. It follows an angular arc between normalized vector directions rather than the chord used by linear interpolation. It isn't an algorithm for discovering a low-loss path around an incompatible-model ridge.
Rather than interpolating directions along a straight line, SLERP[9] interpolates along a sphere. Write the geometry in terms of normalized directions:
where is the interpolation factor (from 0 to 1) and is the angle between normalized weight vectors. Practical merge implementations may handle magnitude separately after interpolating direction, as the code does here.
SLERP was introduced for computer graphics to interpolate rotations represented as quaternions.[9] That source establishes its geometry, not downstream quality for neural-network weight merges. Use a SLERP checkpoint as another candidate and measure loss and required tasks just as you would for a linear merge.
The slerp function interpolates direction on the unit sphere, then linearly interpolates magnitude. Parallel and opposite vectors make too small, so those cases fall back to a straight line.
Make the geometry concrete before running it. For equal-norm vectors [1, 0] and [0, 1], a linear midpoint is [0.5, 0.5] with norm about 0.707; SLERP should land at [0.707, 0.707] with norm 1.0. That prediction is about shape, not model quality.
1import math
2
3def slerp(v0: list[float], v1: list[float], t: float) -> list[float]:
4 def magnitude(vector: list[float]) -> float:
5 return math.sqrt(sum(value * value for value in vector))
6
7 mag0 = magnitude(v0)
8 mag1 = magnitude(v1)
9 if mag0 == 0.0 or mag1 == 0.0:
10 return [(1 - t) * a + t * b for a, b in zip(v0, v1)]
11
12 d0 = [value / mag0 for value in v0]
13 d1 = [value / mag1 for value in v1]
14 dot = max(-1.0, min(1.0, sum(a * b for a, b in zip(d0, d1))))
15 omega = math.acos(dot)
16 sin_omega = math.sin(omega)
17 if abs(sin_omega) < 1e-6:
18 return [(1 - t) * a + t * b for a, b in zip(v0, v1)]
19
20 w0 = math.sin((1 - t) * omega) / sin_omega
21 w1 = math.sin(t * omega) / sin_omega
22 direction = [w0 * a + w1 * b for a, b in zip(d0, d1)]
23 mixed_mag = (1 - t) * mag0 + t * mag1
24 return [value * mixed_mag for value in direction]
25
26v0 = [1.0, 0.0]
27v1 = [0.0, 1.0]
28mid = slerp(v0, v1, t=0.5)
29antipodal_mid = slerp(v0, [-value for value in v0], t=0.5)
30expected = 2**-0.5
31
32print("unit_norm:", abs(math.sqrt(sum(x * x for x in mid)) - 1.0) < 1e-6)
33print("matches_45_degree:", all(abs(a - b) < 1e-6 for a, b in zip(mid, [expected, expected])))
34print("antipodal_fallback_finite:", all(math.isfinite(value) for value in antipodal_mid))
35print("midpoint:", [round(value, 4) for value in mid])
36print("norm:", round(math.sqrt(sum(x * x for x in mid)), 4))1unit_norm: True
2matches_45_degree: True
3antipodal_fallback_finite: True
4midpoint: [0.7071, 0.7071]
5norm: 1.0In the equal-norm orthogonal-vector example above, the SLERP midpoint remains on the unit circle while a linear midpoint would have smaller norm. Keep the distinction clear: geometry explains the candidate's path; task and safety evaluations decide whether it ships. For a pair of checkpoints, score linear and spherical candidates against the same release gates.
Running a merge with mergekit
The concepts now become one artifact. mergekit[1] is an open-source toolkit for merging language-model checkpoints. Its documented CLI supports YAML-defined merges with CPU or limited-VRAM execution, and mergekit-multi can run multi-stage recipes where later merges consume earlier outputs.[1]
Carry the Code, Math, and Chat trio into a YAML file with the base, source weights, density, and merge method. This is a recipe for a candidate, not a release decision:
1models:
2 - model: your-org/base-8b-code
3 parameters:
4 weight: 0.35
5 density: 0.5 # retained fraction for this task vector
6 - model: your-org/base-8b-math
7 parameters:
8 weight: 0.35
9 density: 0.5
10 - model: your-org/base-8b-chat
11 parameters:
12 weight: 0.30
13 density: 0.5
14
15merge_method: dare_ties
16base_model: your-org/base-8b
17tokenizer:
18 source: base # switch to union if you must preserve extra tokens
19chat_template: auto # or pin a specific template when model families differ
20dtype: float16For dare_ties, each source model's density is the keep fraction of its task vector: density = 1 - p in DARE's drop-rate notation. Merge methods have different schemas, so merge_method isn't a drop-in switch. For example, mergekit's slerp method takes exactly two source models.[1]
Tokenizer choice changes the output contract. If all required tokens are in the base tokenizer, tokenizer.source: base pins that vocabulary. Current mergekit configuration defaults to a union tokenizer, which adds source tokens and assigns fallback embeddings where an input model lacks them.[1] Either policy needs targeted evaluation. Before running the YAML, predict which policy preserves Code's special tokens, then use the preflight check below.
A written checkpoint is still a candidate. Run the YAML through mergekit-yaml, score every required slice, and promote only after all gates pass.

⚠️ Common mistake: Assuming all specialist checkpoints use the same tokenizer because they began from the same base. If the Code checkpoint added fill-in-the-middle tokens such as
<fim_prefix>, choosingbasedrops those added output entries while choosingunionintroduces filled embeddings for models that lack them. Verify tokenizer vocabularies, choose the output policy explicitly, and test code-completion prompts that require those tokens.
This preflight check makes the output-vocabulary decision explicit before any expensive merge runs:
1def output_vocab(base_vocab, source_vocabs, policy):
2 if policy == "base":
3 return set(base_vocab)
4 if policy == "union":
5 return set().union(*source_vocabs)
6 raise ValueError("policy must be 'base' or 'union'")
7
8base_vocab = {"<bos>", "def", "return"}
9code_vocab = base_vocab | {"<fim_prefix>"}
10required_tokens = {"def", "<fim_prefix>"}
11
12base_output = output_vocab(base_vocab, [base_vocab, code_vocab], "base")
13union_output = output_vocab(base_vocab, [base_vocab, code_vocab], "union")
14
15print("base_missing_required:", sorted(required_tokens - base_output))
16print("union_missing_required:", sorted(required_tokens - union_output))
17print("union_requires_added_embedding_eval:", "<fim_prefix>" not in base_vocab)1base_missing_required: ['<fim_prefix>']
2union_missing_required: []
3union_requires_added_embedding_eval: TrueOnce the YAML is set, run the merge. The command takes the config file and an output path:
1mergekit-yaml merge_config.yml ./output_model --cudaFor hardware-specific and memory-saving flags, check mergekit-yaml --help because supported options vary by version.[1]
A successful write to disk proves that the recipe ran. It says nothing about Code, Math, or Chat. Evaluate each target slice, compare it with declared gates and source baselines, then retune or reject any candidate that misses a critical threshold.

Make the release call before reading the helper. Does an 87.2 aggregate outweigh Math's 82.1? No. An aggregate ranks candidates, but a release decision fails on any critical threshold miss:
1def failed_release_gates(scores, thresholds):
2 return {
3 task: (scores[task], minimum)
4 for task, minimum in thresholds.items()
5 if scores[task] < minimum
6 }
7
8scores = {"code": 92.4, "math": 82.1, "chat": 87.0}
9thresholds = {"code": 90.0, "math": 85.0, "chat": 84.0}
10failures = failed_release_gates(scores, thresholds)
11
12print("aggregate_score:", round(sum(scores.values()) / len(scores), 1))
13print("failed_tasks:", sorted(failures))
14print("promote:", not failures)1aggregate_score: 87.2
2failed_tasks: ['math']
3promote: FalseFinding good coefficients automatically
The blocked Math slice gives coefficient search a concrete objective. Searching merge coefficients and layer selections by hand can be expensive. Evolutionary model merging[10] searches merge recipes against a supplied fitness evaluation. The search space can include per-layer source choices, interpolation weights, and whether to merge in parameter space, data-flow space, or both.
Sakana AI reports using this approach to build EvoLLM-JP from Japanese-language and math-oriented models, optimizing for the evaluations selected in that work.[10] The loop is simple: score a recipe on validation data, mutate its parameters, and keep better-scoring recipes. The search inherits the objective's coverage and blind spots, so independent release slices still matter.
Before reaching for evolution, use a small grid as a transparent baseline. Try lambda values in [0.2, 0.4, 0.6, 0.8] for each task vector, measure each required slice on held-out data, and keep only configurations that clear every release gate. For Code, Math, and Chat, include code-generation tests, math word problems, and instruction-following prompts. A code-only objective can't protect Math.
Passthrough and frankenmerging
Not every merge averages weights. Mergekit's passthrough method copies selected tensors or layer ranges from source models into the output checkpoint.[1] This is model splicing rather than interpolation: tensor dimensions must compose, while useful behavior across the splice remains an evaluation result.
Suppose a recipe copies layers 0-19 from Code and layers 20-31 from Math. Compatible dimensions make that artifact constructible; they don't show that Math's later layers can interpret the hidden states produced by Code's earlier layers.
Use passthrough as an experiment with explicit source lineage, layer boundaries, and the same per-task gates used for any other candidate. A successful build proves shape compatibility only.
Merging pitfalls and hard limits
Direct weight merging requires aligned parameter meaning, shape, and output-space assumptions. You can't directly interpolate a 7-billion-parameter checkpoint with a 70-billion-parameter checkpoint, or silently combine incompatible vocabulary mappings and treat token IDs as equivalent. A generated artifact is a candidate, not evidence of retained quality.
Use these stop signs before spending time tuning coefficients. Some block construction; others block release even when construction and average quality look good.
Avoid direct interpolation when:
- Different architectures: Source models must share the same structural architecture, parameter count, and layer layout. You can't directly interpolate a Llama checkpoint with a Mistral checkpoint, or an 8B model with a 70B model.
- Different tokenizers: Direct interpolation assumes aligned embeddings and next-token heads. Mergekit can help with tokenizer union in compatible cases, but it doesn't make arbitrary vocabulary mismatches disappear.[1]
- Mismatched prompt formats or chat templates: These usually won't block the raw tensor merge, but they can make the merged checkpoint look broken at inference time because the prompt serialization no longer matches the behaviors learned during tuning.
- Quantized-only checkpoints: If you want to merge, do it on dequantized or full-precision weights first, then quantize the final artifact. The merge math depends on real-valued deltas, not already-rounded integers. Treat the merged checkpoint as a new model and re-run your quantization calibration and evals.
- Distant fine-tuning trajectories: Models fine-tuned on divergent data or with large update magnitudes may be poor interpolation candidates. Git Re-Basin demonstrates permutation alignment in studied MLP, CNN, and ResNet settings; it isn't an established repair step for arbitrary LLM merges.[5]
- Critical precision tasks: Don't ship a merge when a required slice misses its threshold, even if an aggregate metric rises. A specialist baseline remains part of the comparison.
Diagnosing a broken merge
Diagnose in order. First verify the artifact contract: keys, shapes, tokenizer, output head, and chat template. Then compare behavior with source baselines on each task slice. Only after those checks should you tune coefficients or choose another merge rule.
When a merge goes wrong, the model usually tells you quickly:
| Symptom | Likely Cause | Fix |
|---|---|---|
| Output is incoherent or random tokens | Tokenizer/output mapping, prompt template, or architecture mismatch | Verify output vocabulary policy, chat template, and layer shapes |
| Model loops repetitive text | Coefficient overload or weights pushed too far out of distribution | Reduce coefficient magnitudes, start additive lambdas in the 0.0-1.0 range, and re-run evals |
| Merged model is worse than every source | Source interference or incompatible lineage | Check source lineage and tokenizer policy; prefer compatible sources and retune or reject the merge |
The original Model Soups gains in shared-initialization ImageNet experiments justify trying an average. They don't predict that this Code, Math, and Chat merge will pass.[3] Promotion still requires a clean artifact, retained behavior, and every binding gate.