andyoneal's picture
Ecce Vectors: 29 control vectors for Gemma 4 31B roleplay fine-tunes, with metrics, blind-test results, examples and the fitting code
e51b495 verified
Raw History Blame Contribute Delete
6.22 kB
"""Rebuild a merge from its parents' control vectors: apply a mergekit recipe to the vectors, layer by layer,
and bake the result into one GGUF.
A linear-family merge on stock moves the model by sum_i w_i(L) * delta_i in expectation, so the merge's vector
is the same weighted sum of the parents' vectors at each layer:
direction.L = sum_i w_i(L) * parent_i.direction.L
w_i(L) is resolved as mergekit resolves it (mergekit/config.py evaluate_setting and mergekit/plan.py): a list
is a gradient interpolated at t = L / 59, conditional values take the first matching filter, then each
method's normalize default and lambda. The parent files are released GGUFs, which already carry their fitted
strength, so the baked file loads at 1.0 like any of them.
python -m ecce_vector.bake recipe.yaml merge.gguf \\
--parent Org/Parent-A=vectors/parent-a.gguf --parent Org/Parent-B=vectors/parent-b.gguf
Per-layer weights are what a single llama.cpp --control-vector-scaled weight per file cannot express; a
recipe with one constant weight per parent is just a launch-time blend and needs no baking.
Handled: linear, task_arithmetic, della_linear, dare_linear, and ties / dare_ties / della / breadcrumbs(_ties)
without their sign-election step (a weighted sum cannot reproduce it: a warning, and untested). Refused: other
methods (slerp, model_stock, ...), a base other than stock gemma-4-31B-it, and a recipe that weights a layer's
projections differently (one vector per layer cannot carry that).
"""
from __future__ import annotations
import argparse
import numpy as np
import yaml
from .io import N_LAYERS, read_gguf, write_gguf
STOCK = "gemma-4-31b-it"
LINEAR = {"linear", "task_arithmetic", "ties", "dare_ties", "dare_linear", "della", "della_linear", "breadcrumbs",
"breadcrumbs_ties"}
# mergekit/merge_methods/registry.py: each method's default for `normalize` (linear.py defaults it to true)
NORMALIZE_DEFAULT = {"linear": True, "task_arithmetic": False, "ties": True, "dare_ties": False, "dare_linear": False,
"breadcrumbs": False, "breadcrumbs_ties": False, "della": True, "della_linear": False}
SIGN_ELECTION = {"ties", "dare_ties", "breadcrumbs_ties", "della"}
# one representative tensor per projection family (v_proj is absent on the full-attention layers)
TENSORS = ("self_attn.q_proj.weight", "self_attn.k_proj.weight", "self_attn.o_proj.weight", "mlp.gate_proj.weight",
"mlp.up_proj.weight", "mlp.down_proj.weight")
PREFIX = "model.language_model.layers."
def evaluate(name: str, setting, t: float):
"""mergekit's evaluate_setting, numeric settings only."""
if isinstance(setting, (int, float)):
return float(setting)
if isinstance(setting, list):
if all(isinstance(e, (int, float)) for e in setting):
scaled = t * (len(setting) - 1)
i0 = int(scaled)
i1 = min(len(setting) - 1, i0 + 1)
return (1 - (scaled - i0)) * setting[i0] + (scaled - i0) * setting[i1]
for cond in setting:
f = cond.get("filter")
if f is None or f == "*" or f in name:
return evaluate(name, cond["value"], t)
return None
raise ValueError(f"unsupported setting {setting!r}")
def layer_weights(cfg: dict) -> tuple[dict[str, list[float]], list[tuple[str, int]]]:
"""Per parent, its weight at each layer; and the (parent, layer) pairs whose projections disagree."""
method = cfg["merge_method"]
if method not in LINEAR:
raise SystemExit(f"{method}: not a linear-family merge; a weighted sum of vectors cannot reproduce it")
params = cfg.get("parameters") or {}
lam = float(params.get("lambda", 1.0))
normalize = params.get("normalize", NORMALIZE_DEFAULT[method])
out, mixed = {}, []
for m in cfg["models"]:
if m["model"] == cfg.get("base_model"):
continue # the base contributes no delta and is not in the normalizing sum
weights = []
for L in range(N_LAYERS):
vals = [evaluate(f"{PREFIX}{L}.{tn}", (m.get("parameters") or {}).get("weight", 1.0), L / (N_LAYERS - 1))
for tn in TENSORS]
if len(set(round(v, 9) for v in vals)) > 1:
mixed.append((m["model"], L))
weights.append(vals[-1])
out[m["model"]] = weights
if normalize:
for L in range(N_LAYERS):
s = sum(out[k][L] for k in out)
for k in out:
out[k][L] = out[k][L] / s if s else 0.0
return {k: [lam * w for w in v] for k, v in out.items()}, mixed
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("recipe", help="the merge's mergekit YAML")
ap.add_argument("out", help="output .gguf")
ap.add_argument("--parent", action="append", required=True,
help="MODEL=path.gguf, MODEL exactly as the recipe names it")
ap.add_argument("--name", help="general.name metadata")
a = ap.parse_args(argv)
cfg = yaml.safe_load(open(a.recipe))
base = str(cfg.get("base_model", "")).rstrip("/").split("/")[-1].lower()
if base != STOCK:
raise SystemExit(f"base_model {cfg.get('base_model')!r}: parent vectors are deltas from stock gemma-4-31B-it")
if cfg["merge_method"] in SIGN_ELECTION:
print(f"warning: {cfg['merge_method']} elects signs per element; the weighted sum ignores that (untested)")
w, mixed = layer_weights(cfg)
if mixed:
raise SystemExit(f"projections weighted differently within layers {sorted({L for _, L in mixed})}")
files = dict(p.split("=", 1) for p in a.parent)
if set(files) != set(w):
raise SystemExit(f"recipe models {sorted(w)} vs --parent {sorted(files)}")
V = np.zeros_like(read_gguf(next(iter(files.values()))))
for m, f in files.items():
V += np.asarray(w[m], np.float32)[:, None] * read_gguf(f)
write_gguf(a.out, V, name=a.name)
for m in w:
print(f"{m:50s} weight at L1 {w[m][1]:.3f} L30 {w[m][30]:.3f} L59 {w[m][59]:.3f}")
print(f"wrote {a.out}: {cfg['merge_method']}, {len(w)} parents; load it at 1.0")
if __name__ == "__main__":
main()