Skip to content

Instantly share code, notes, and snippets.

@timm
Last active June 27, 2026 03:56
Show Gist options
  • Select an option

  • Save timm/895119b1699fad5b19124f56c2ae390c to your computer and use it in GitHub Desktop.

Select an option

Save timm/895119b1699fad5b19124f56c2ae390c to your computer and use it in GitHub Desktop.
ezr2 — explainable multi-objective optimization (landscape + tree). http://tiny.cc/ezr2

AuthorLanguageDepsLicensePurpose

ezr2 — explainable multi-objective optimization in one file, zero dependencies, pure Python stdlib. Build a large pool of candidate rows, recursively split it by far-point projection (a descendant of SWAY), label only a few dozen informative rows, then sort the rest. A regression or classification tree explains which input ranges lead to the best goals, and a branch-delta reads as an action. Runs on a CSV, or a live model (override labelled()).

# sibling data gist supplies the CSVs (no data lives in here)
git clone http://tiny.cc/optimiz                 # optimization data
git clone http://tiny.cc/ezr2 && cd ezr2
python3 test_ezr2.py tree                         # build + show a tree
python3 test_ezr2.py all                          # run every self-test
make test                                         # same, via konfig

Sections: NAME | SYNOPSIS | DESCRIPTION | DATA | OPTIONS | FINDINGS | SEE ALSO | LICENSE | AUTHOR

Files: ezr2.py | test_ezr2.py | dtlz.py | Growing.py | pctl.py | ezr2.md | Random.md | Growing.md | Makefile | pyproject.toml | LICENSE.md

NAME

ezr2 - explainable multi-objective optimization via active
       learning and a dual-mode (regression|classification) tree

SYNOPSIS

python3 test_ezr2.py [--key=val ...] <test>
python3 test_ezr2.py -h | all
python3 dtlz.py [--model=dtlz1..dtlz7] [--M=int] [--N=int]

Sibling gists (one parent dir; no naked paths):
  ezr2/     this repo (ezr2.py library + test_ezr2.py dispatch)
  optimiz/  optimization CSVs   (tiny.cc/optimiz)
  konfig/   shared Makefile + dotfiles (make help|sh|vi|...)

DESCRIPTION

Summarizes a CSV into Num/Sym columns in constant space; scores
each row by distance-to-heaven over its goals; spends a small
label budget via SWAY-style far-point projection; then grows a
tree that minimizes a size-weighted variance (sd for numeric
goals, entropy for symbolic) so the SAME code yields regression
or classification trees. A leaf is a real cluster; the delta
between two leaves' branch tests is a feasible intervention.

Two modes: a static CSV, or a live model -- override the
`labelled()` seam so goals are computed on demand (see dtlz.py,
which drives ezr2 over the DTLZ1-7 benchmarks).

Tests live in test_ezr2.py (`from ezr2 import *; main(globals())`);
the library ezr2.py has no tests inside it.

DATA

First row names the columns; a name's last char sets its role,
its first char its type:
  [A-Z]*    numeric        (e.g. "Clndrs")
  [a-z]*    symbolic       (e.g. "origin")
  *+        maximize goal  (e.g. "Mpg+")
  *-        minimize goal  (e.g. "Lbs-")
  *!        class label    (e.g. "sick!")
  *X        ignored        (e.g. "HpX")
  *~        protected x-column

E.g. Clndrs,Volume,HpX,Model,origin,Lbs-,Acc+,Mpg+ -> numeric
inputs Clndrs/Volume/Model, symbolic input origin, ignored HpX,
goals minimize Lbs, maximize Acc/Mpg.

OPTIONS

--file   data file             = ../optimiz/misc_auto93.csv
--seed   random seed           = 1
--leaf   tree min leaf rows    = 3
--maxd   tree max depth        = 8
--grow   add labels/round      = 4
--budget labeling cap          = 50
--cap    max rows kept         = 1024
--check  rows labelled by tree = 5    (at-k trust knob)
--keepf  keep frac             = 0.66
--round  decimals shown        = 3
--landscape  active | random   = active

FINDINGS

Active beats random ~3:1 where landscapes separate, ties at the
sparsity ceiling; the edge lives in the hard tail, not the mean
(Random.md). `grow` is the dominant knob and SWAY's (keepf=0.5,
grow=2) is the weakest setting in its own space -- growing a
little faster gains ~11 win points across a smooth, low-
sensitivity plateau (Growing.md).

SEE ALSO

ezr2.md       the tour -- a build-order textbook of the code
dtlz.py       drive ezr2 with an external model (DTLZ1-7)
tiny.cc/optimiz   sibling data gist (CSVs; never bundled here)
tiny.cc/ezr       the original ezr (v1)

LICENSE

MIT (c) 2026 Tim Menzies <timm@ieee.org>.

AUTHOR

Tim Menzies, http://timm.fyi
__pycache__/
*.pyc

Growing Faster Beats SWAY

ezr2's sampler is a descendant of SWAY [Chen et al., TSE 2018, arXiv:1608.07617]: build a large random pool, recursively split it by far-point projection, label only a few rows per round, cull the worse half. Two knobs control it:

  • keepf — fraction of the pool kept each round (SWAY: 0.5; ezr2: 0.66).
  • grow — rows labelled per round (SWAY: 2; ezr2: 4).

SWAY was deliberately conservative: keep half, grow slowly. Is that the right setting? We tested it.

Method

Growing.py: 100,000 random draws from the grid keepf in {0.50..0.80} x grow in {2..10}, over 20 datasets x 20 seeds (rows capped at 256). For each draw we record the win delta vs the SWAY baseline (0.5, 2) on the same dataset and seed. Because the baseline is itself a grid point, its cell (keepf=0.50, grow=2) reads exactly 0; every other cell is its win gain.

Result

delta win vs SWAY baseline (keepf=0.5, grow=2), N=100000

keepf\grow    2    3    4    5    6    7    8    9   10
0.80         12   13   14   13   12   13   11   11   11
0.75         11   13   13   12   12   12   11   12   12
0.70          9   11   12   13   12   13   12   12   12
0.65          8   10   12   13   12   13   13   12   13
0.60          6    8   10   13   12   13   13   12   13
0.55          4    7   10   10   11   11   12   11   12
0.50          0    7    8   10   10   11   12   13   12

best 14   worst 0   mean 11.1

Win delta vs SWAY baseline (keepf x grow)

Every cell off the baseline is positive. SWAY's (0.5, 2) is the single worst point; any move away from it gains win, by 11 points on average.

Reading the surface:

  • Both knobs help, and they trade off. From the (0.50, 2) corner (bottom-left) you can climb by raising grow (along the bottom row: 0 -> 8 -> 13) or by raising keepf (up the left column: 0 -> 8 -> 12). Either path reaches the ~12-14 plateau.
  • grow saturates early. Most of its gain is in by grow=4-5; beyond that the row is flat. SWAY's grow=2 is the one clearly starved setting -- two rows per round is too little evidence to cull a split well.
  • keepf is monotone up to ~0.75. Higher keep culls less aggressively and helps, easing off by 0.80 (keep too much and narrowing stalls at high grow).
  • The plateau is broad and smooth. Across the whole interior the delta sits at 11-14 -- the method is insensitive once you leave SWAY's corner. No knife-edge to tune.

Takeaways

  1. SWAY under-grows. Its (0.5, 2) is the weakest point in this space; conservatism cost it ~11 win points.
  2. ezr2's defaults (0.66, 4) are well placed -- on the plateau (~12) -- and already capture nearly all the available gain.
  3. Low sensitivity is the headline. Because the surface is a smooth positive plateau, these knobs do not need per-dataset tuning; pick anything in keepf 0.65-0.80, grow 4-9 and you are near the top.

The story mirrors the active-vs-random result: the gains come from spending a little more evidence where it matters, and the method is robust to the exact amount.

#!/usr/bin/env python3 -B
"""
Growing.py: sensitivity of landscape's keepf/grow knobs (see Growing.md).
SWAY [Chen'18] used keepf=0.5, grow=2; ezr2 defaults to 0.66, 4.
We draw 100,000 random (keepf, grow) points from the grid
keepf in {0.50..0.80}, grow in {2..10}, over 20 datasets x 20 seeds, and
record the win DELTA vs the SWAY baseline (0.5, 2) on the same
dataset+seed. The baseline grid point reads exactly 0.
Output: Growing.png heat map + an ASCII grid on stdout.
"""
import glob, random
from ezr2 import Data, csv, some, wins, landscape, the
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
N = 100_000
SEEDS = range(20)
CAP = 256
GROWS = list(range(2, 11)) # 2..10 (9 cols)
KEEPFS = [0.5,0.55,0.6,0.65,0.7,0.75,0.8] # exact keepf (7 rows)
the.landscape = "active"
# 20 fast datasets (few columns, enough rows)
files = []
for f in sorted(glob.glob("../optimiz/*.csv")):
with open(f) as fh: cols = len(fh.readline().split(","))
if cols <= 16: files.append(f)
files = files[:20]
data, W = {}, {}
for f in files:
d = Data(csv(f)); d.rows = some(d.rows, CAP)
data[f] = d; W[f] = wins(d)
def win(f, keepf, grow, seed):
the.keepf, the.grow = keepf, grow
random.seed(seed)
return W[f](landscape(data[f])[0])
# baseline (SWAY: keepf=0.5, grow=2) cached per (dataset, seed)
base = {(f,s): win(f, 0.5, 2, s) for f in files for s in SEEDS}
ssum = [[0.0]*len(GROWS) for _ in KEEPFS]
scnt = [[0 ]*len(GROWS) for _ in KEEPFS]
rng = random.Random(0)
for _ in range(N):
f = rng.choice(files); s = rng.choice(list(SEEDS))
keepf = rng.choice(KEEPFS); grow = rng.randint(2, 10)
d = win(f, keepf, grow, s) - base[(f,s)]
i, j = KEEPFS.index(keepf), grow-2
ssum[i][j] += d; scnt[i][j] += 1
grid = [[(ssum[i][j]/scnt[i][j] if scnt[i][j] else 0.0)
for j in range(len(GROWS))] for i in range(len(KEEPFS))]
# --- ASCII grid --- (rows high->low so keepf=0.5 is at the bottom,
# matching Growing.png's origin="lower"; baseline cell is exactly 0)
print("delta win vs SWAY baseline (keepf=0.5, grow=2), N=%d\n" % N)
print("keepf\\grow " + " ".join("%4d" % g for g in GROWS))
for i in reversed(range(len(KEEPFS))):
print("%-9s " % ("%.2f" % KEEPFS[i]) + " ".join("%4.0f" % grid[i][j]
for j in range(len(GROWS))))
flat = [grid[i][j] for i in range(len(KEEPFS)) for j in range(len(GROWS))]
print("\nbest cell %.0f worst cell %.0f mean %.1f" %
(max(flat), min(flat), sum(flat)/len(flat)))
# --- heat map ---
M = max(abs(min(flat)), abs(max(flat)))
fig, ax = plt.subplots(figsize=(7,4))
im = ax.imshow(grid, aspect="auto", origin="lower", cmap="RdBu",
vmin=-M, vmax=M)
ax.set_xticks(range(len(GROWS))); ax.set_xticklabels(GROWS)
ax.set_yticks(range(len(KEEPFS)))
ax.set_yticklabels(["%.2f"%k for k in KEEPFS])
ax.set_xlabel("grow (labels per round)"); ax.set_ylabel("keepf (kept fraction)")
ax.set_title("Win delta vs SWAY baseline (keepf=0.5, grow=2)")
for i in range(len(KEEPFS)):
for j in range(len(GROWS)):
c = "white" if abs(grid[i][j]) > 0.6*M else "black" # contrast on dark cells
ax.text(j, i, "%.0f"%grid[i][j], ha="center", va="center",
fontsize=8, color=c)
fig.colorbar(im, label="mean win delta")
fig.tight_layout(); fig.savefig("Growing.png", dpi=130)
print("\nwrote Growing.png")

Python 3.14 Purpose XAI Goal Multi-Obj Teaching Deps 0 LOC 300 License

MIT License

Copyright (c) 2026 Tim Menzies timm@ieee.org

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

# vim: ts=2 sw=2 sts=2 et :
# knobs only; shared targets live in $(KONFIG)/Makefile
KONFIG ?= ../konfig
APP := ezr2
MAIN := test_ezr2.py
EXT := py
LANG := python
SRC := *.py
LINT := ruff check ezr2.py test_ezr2.py
TOOLS := python3:run ruff:lint
PKG := python3 gawk ruff neovim tmux
Font := 4.7 # a touch smaller than konfig's 5, so ezr2.py fits 6 cols
$(KONFIG)/Makefile:
@test -f $@ || { echo "missing konfig: git clone http://tiny.cc/konfig $(KONFIG)"; exit 1; }
include $(KONFIG)/Makefile
# ---- test lanes + studies (repo-specific; after the include) ----
DATA ?= ../optimiz
test: ## run every self-test (resets seed each)
@python3 -B test_ezr2.py all
HOLD := $(HOME)/tmp/konfig/ezr2_holdouts.log
$(HOLD): ## holdouts landscape-vs-random over all $(DATA), percentiles
@mkdir -p $(@D)
@ls $(DATA)/*.csv | (gshuf 2>/dev/null || sort -R) | \
xargs -P 12 -I{} python3 -B -u test_ezr2.py holdouts --file={} 2>/dev/null \
| tee $@
@python3 -B pctl.py < $@
PURE := $(HOME)/tmp/konfig/ezr2_pure.log
$(PURE): ## pure search land-vs-random (no tree) over all $(DATA)
@mkdir -p $(@D)
@ls $(DATA)/*.csv | (gshuf 2>/dev/null || sort -R) | \
xargs -P 12 -I{} python3 -B -u test_ezr2.py pure --file={} 2>/dev/null \
| tee $@
@python3 -B pctl.py < $@
grow: ## keepf/grow sensitivity study -> Growing.png + Growing.md grid
@python3 -B Growing.py

Is Random as Good as Anything?

A recurring worry in active learning: on a single easy dataset, random sampling looks as good as a clever acquisition function. If true, four years of active-learning research collapses. This note tests it on 129 SE optimization datasets and finds it false — but only once you (1) fix a bug, (2) ignore the mean, and (3) read the tail.

The scare

On misc_auto93.csv (~400 rows), 20 shuffles of active landscape vs a same-budget random pick:

random wins 10,  active wins 6,  tie 4

Random beat active. But auto93 is tiny and easy: every method lands at disty 0.075–0.17. When the good corner is trivially reachable, nothing separates. One easy dataset proves nothing.

The proper test

ezr2.py holdouts|pure runs active vs random over a whole corpus (20 seeds each, same() verdict per dataset). First run silently dropped 34/129 datasets — a bug, not weak results.

Bug. The split criterion derives the right-hand variance by subtraction (mix(tot, me, -1)). Floating-point underflows m2 slightly negative; sd = sqrt(m2) then returns a complex number and crashes. The old sum-of-m2 criterion never took a square root, so the fault was latent until we switched to a uniform var = sd | entropy criterion. Fixed by clamping m2 >= 0 at the source. All 129 now run.

Verdict (129/129)

lane active tie random
holdouts (tree) 41 74 14
pure (no tree) 55 53 21

Active beats random ~3:1 in both. But ~half the corpus ties — the sparsity ceiling: most datasets put the good rows in a small corner any competent method reaches. This is why the means look flat (pure: 79.5 vs 78.6).

The mean lies; the tail tells

Pure-search win, by percentile (low win = hard dataset):

WIN pct       10    30    50    70    90
active      72.3  87.3  94.0  97.3  99.9
random      68.7  83.8  92.9  96.2  99.9
gap         +3.6  +3.5  +1.1  +1.1   0.0

The advantage is monotonic in difficulty. Active does not make easy problems easier (p90: both maxed out); it rescues the hard ones (p10/p30). Averaging over the easy ceiling washes out an edge that is real and concentrated in the tail. Report the distribution, not the mean.

A cost of interpretability

The tree lane (learn on train split, pick from unseen test split) muddies the tail:

WIN pct       10    30    50    70    90
active      50.6  72.6  86.1  94.9  99.4
random      52.1  68.7  86.2  92.3  99.6
gap         -1.5  +3.9  -0.1  +2.6  -0.2

Active wins the mid-tail but loses at p10. The hold-out pick is a high-variance estimator, and the hardest datasets are often the smallest, so its noise erases active's edge exactly where data is thinnest. The tree buys interpretability (which x-ranges win); on thin, hard data that is not free.

Conclusion

Random is not as good as anything. It ties on the easy majority (ceiling), but where separation is possible active wins ~3:1, with its advantage growing as problems get harder. The illusion of parity comes from three mistakes: trusting one easy dataset, letting a crash silently drop hard datasets, and reading the mean instead of the tail.

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment