Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save timm/cf818571287c22c381305072229be859 to your computer and use it in GitHub Desktop.
ezr.py (v0.5): lightweight XAI for multi-objective optimization

AuthorLanguageDepsLicensePurpose

ezr — explainable multi-objective optimization. Two files, ~1100 lines, zero dependencies, pure Python stdlib. An experiment in "how low can you go?": active learning labels a few dozen informative rows, builds a regression tree, and sorts the rest. Repeated studies show that labelling just the first ~5 examples optimizes as well or better than SMAC — at two orders of magnitude less cost.

# sibling data gists supply the CSVs (no data lives in here)
git clone http://tiny.cc/optimiz       # optimization data
git clone http://tiny.cc/klassif       # classification data
git clone http://tiny.cc/ezr && cd ezr
python3 cli.py --list                  # all commands
python3 cli.py --tree ../optimiz/auto93.csv
python3 cli.py --all                   # run every self-test

Sections: NAME | SYNOPSIS | DESCRIPTION | DATA | COMMANDS | OPTIONS | LAYOUT | LICENSE | AUTHOR

Files: ezr.py | cli.py | Makefile | pyproject.toml | LICENSE.md

NAME

ezr - explainable multi-objective optimization via decision
      trees, clustering, naive bayes, and active learning

SYNOPSIS

python3 cli.py [--key=val ...] --<name> [FILE]
python3 cli.py --list | --fast | --slow | --all | --help
p                          # konfig bashrc alias: python3 -B cli.py

Sibling gists (one parent dir; no naked paths):
  ezr/      this repo (ezr.py library + cli.py dispatch)
  optimiz/  optimization CSVs   (tiny.cc/optimiz)
  klassif/  classification CSVs (tiny.cc/klassif)
  textz/    text-mining CSVs    (tiny.cc/textz)
  konfig/   shared Makefile + dotfiles (make help|sh|vi|...)

DESCRIPTION

Summarizes CSV into Num/Sym columns; grows decision trees that
minimize distance to the ideal outcome; clusters via k-means or
recursive halving; classifies + actively learns with naive bayes
or centroid acquisition. Input is CSV; the header defines roles
(see DATA). Stdlib only, Python 3.12+.

DATA

Header column names declare each role:
  [A-Z]*    numeric        (e.g. "Age")
  [a-z]*    symbolic       (e.g. "job")
  [A-Z]*+   maximize goal  (e.g. "Mpg+")
  [A-Z]*-   minimize goal  (e.g. "Lbs-")
  [a-z]*!   class label    (e.g. "sick!")
  *X        ignored        (e.g. "idX")
  ?         missing value  (in rows, not the header)

COMMANDS

each `test_<name>` in cli.py is one command (demo + self-check),
run via `--<name>`. No FILE -> default dataset; FILE -> that CSV.
  --core       primitives: Num/Sym/Data/distance/format
  --tree       grow + show a regression tree, check plans
  --cluster    k-means++ / k-means / recursive halving
  --classify   naive bayes beats ZeroR        (needs ../klassif)
  --search     sa | ls | de optimizers (energy trace)
  --acquire    active learning beats random (20 reps)
  --acquire20  hold-out tree win (acquire half, sort the other)
  --textmine   CNB + tf-idf text mining        (needs ../textz)
  --stats      same / bestRanks / confused
lanes: --fast (skip slow) | --slow (textmine) | --all

OPTIONS

--seed=1            random seed
--p=2               distance (1,2 = Manhattan, Euclid)
--few=128           max rows kept while sampling
--learn.leaf=3      examples per tree leaf
--learn.start=4     initial labels
--learn.budget=50   rows allowed to be labelled
--learn.check=5     guesses to check
--bayes.m=2         m-estimate    --bayes.k=1   laplace
(full list: head of ezr.py; override any as --key=val)

LAYOUT

ezr.py   library; section banners per app (Types, Col, Data,
         Distance, Bayes, Tree, Cluster, Classify, Search,
         Acquire, Textmine, Stats, Format)
cli.py   dispatch; one test_<name> per concept (demo + assert),
         run via --<name>; --fast/--slow/--all lanes

LICENSE

MIT. https://choosealicense.com/licenses/mit/

AUTHOR

Tim Menzies <timm@ieee.org>
__pycache__/
*.pyc
___ _____ __
/ _ \_ / '__|
| __// /| |
\___/___|_|
explainable multi-objective optimization. http://tiny.cc/ezr
"find the best rows; label the fewest."
#!/usr/bin/env python3 -B
"""cli.py: ezr command-line. One test_<name> per concept; each both
demonstrates (prints) and checks (asserts).
Usage:
ezr [--key=val ...] --<name> [FILE]
ezr --list list all commands
ezr --fast run quick tests (skip slow)
ezr --slow run slow tests only
ezr --all run every test
ezr --help show this help
A command is the name after `test_`. With no FILE it uses a default
dataset; with a FILE arg it uses that CSV. Examples:
ezr --tree ../optimiz/auto93.csv
ezr --classify ../klassif/diabetes.csv
ezr --learn.budget=256 --acquire20 ../optimiz/auto93.csv
ezr --core
"""
import sys, random, traceback
from pathlib import Path
from ezr import *
# ---- Default data files (sibling data gists; override via FILE arg) ----
EGOPT1 = Path("../optimiz/auto93.csv")
EGCLASS1 = Path("../klassif/soybean.csv")
EGCLASS2 = Path("../klassif/diabetes.csv")
EGCNB = Path("../textz/Hall.csv")
EGTXT = Path("../textz/Hall_raw.csv")
SLOW = {"textmine"} # commands too slow for the --fast lane
def ready(file):
"""Shuffle, split data into (full, train, test_rows)."""
d = file if Data == type(file) else Data(csv(str(file)))
random.shuffle(d.rows)
half = len(d.rows) // 2
return (d, clone(d, d.rows[:half][:the.few]), d.rows[half:])
def need(f):
"""Return path string if it exists, else None."""
p = Path(f)
if not p.exists():
print(f"missing: {f}"); return None
return str(p)
# ============================================================
# Commands: test_<name>(*argv) -- demo + assert, default-or-FILE
# ============================================================
def test_core(*argv):
"""Core primitives: Num, Sym, Data, distance, format."""
assert o(3.14159).startswith("3.14")
assert thing("3.14") == 3.14
t = S(); nest(t, "a.b.c", 42); assert t.a.b.c == 42
c = adds([10, 20, 30, 40, 50], Num())
assert c.mu == 30 and 15.8 < spread(c) < 15.9
c = adds("aaabbc", Sym())
assert mid(c) == "a" and 1.4 < spread(c) < 1.5
cols = Cols(["name", "Age", "Weight-"])
assert not cols.ys[0].heaven and len(cols.xs) == 2 and len(cols.ys) == 1
f = need(argv[0]) if argv else need(EGOPT1)
if not f: return
d = Data(csv(f)); assert len(d.rows) > 0
assert distx(d, d.rows[0], d.rows[0]) == 0
ds = [disty(d, r) for r in d.rows]
assert min(ds) >= 0 and max(ds) <= 1.0001
print("ok test_core")
def test_tree(*argv):
"""Grow + show a regression tree; check leaves + counterfactual plans."""
f = need(argv[0]) if argv else need(EGOPT1)
if not f: return
_, d_train, _ = ready(f)
t = treeGrow(d_train, d_train.rows)
treeShow(t)
assert t.left is not None and t.right is not None
d, d_train, _ = ready(f)
t = treeGrow(d_train, d_train.rows)
here = treeLeaf(t, max(d.rows, key=lambda r: disty(d, r)))
plans = sorted(treePlan(t, here))
assert plans, "treePlan produced no counterfactuals"
print("ok test_tree")
def test_cluster(*argv):
"""kmeans++ / kmeans / rhalf; show clusters, check sizes."""
f = need(argv[0]) if argv else need(EGOPT1)
if not f: return
d = Data(csv(f))
cents = kpp(d, k=10); assert len(cents) == 10
ds = kmeans(d, k=10, cents=cents); assert len(ds) >= 1
for c in ds:
print(f" :n {len(c.rows):>4} :centroid {o(mids(c))}")
assert len(rhalf(d, k=10)) >= 1
print("ok test_cluster")
def test_search(*argv):
"""sa / ls / de optimizers; show energy trace, check it decreases."""
f = need(argv[0]) if argv else need(EGOPT1)
if not f: return
d0 = Data(csv(f)); shuffle(d0.rows)
known = clone(d0, d0.rows[:50])
srch = clone(d0, d0.rows[50:])
oracle = lambda r: oracleNearest(known, r)
for name, fn in [("sa", lambda: sa(srch, oracle, budget=500)),
("ls", lambda: ls(srch, oracle, budget=500)),
("de", lambda: de(srch, oracle, budget=2000))]:
es = [e for _, e, _ in fn()]
assert es and es[-1] <= es[0], f"{name} regressed: e0={es[0]} eN={es[-1]}"
print(f" {name}: {len(es)} improvements, e0={o(es[0])} eN={o(es[-1])}")
print("ok test_search")
def test_acquire(*argv):
"""Active learning beats a random baseline over 20 reps."""
f = need(argv[0]) if argv else need(EGOPT1)
if not f: return
d0 = Data(csv(f))
w1, w_rand = Num(), Num()
win = wins(d0)
for _ in range(20):
d, d_train, test_rows = ready(d0)
lab = acquire(d_train)
add(w1, win(min(lab.rows[:the.learn.check], key=lambda r: disty(d_train, r))))
add(w_rand, win(min(sample(test_rows, the.learn.check),
key=lambda r: disty(d_train, r))))
print(f":acquire {int(mid(w1))} :rand {int(mid(w_rand))}")
assert mid(w1) > mid(w_rand), f"acquire {mid(w1):.1f} <= rand {mid(w_rand):.1f}"
print("ok test_acquire")
def test_acquire20(*argv):
"""Hold-out win: acquire+tree on one half, tree sorts the other, top-check.
Prints ONE line `<win> <file>` (win is $1, for `gawk '{print $1}'|sort -n`)."""
f = need(argv[0]) if argv else need(EGOPT1)
if not f: return
w = holdoutWin(Data(csv(f)))
print(f"{w:.0f}\t{Path(f).name}")
assert w > 50, f"hold-out win too low: {w}"
def test_classify(*argv):
"""Naive Bayes beats ZeroR (90/10 split, 20 reps)."""
f = need(argv[0]) if argv else need(EGCLASS2)
if not f: return
d = Data(csv(f)); k = d.cols.klass.at
rows = list(csv(f)); header, body = rows[0], rows[1:]
def zeroR(train, test):
cs = {}
for r in train: cs[r[k]] = cs.get(r[k], 0) + 1
maj = max(cs, key=cs.get)
return sum(1 for r in test if r[k] == maj) / (len(test) or 1e-32)
def nbBatch(train, test):
h, all = {}, Data([header])
for r in train:
w = r[k]; h.setdefault(w, clone(all)); add(all, add(h[w], r))
ok = 0
for r in test:
got = max(h, key=lambda kl: likes(h[kl], r, len(all.rows), len(h)))
ok += int(got == r[k])
return ok / (len(test) or 1e-32)
nb_a, zr_a = [], []
for i in range(20):
random.seed(the.seed + i)
sh = body[:]; random.shuffle(sh)
sp = int(0.9 * len(sh))
tr, te = sh[:sp], sh[sp:]
nb_a.append(nbBatch(tr, te))
zr_a.append(zeroR(tr, te))
nb, zr = sum(nb_a)/len(nb_a), sum(zr_a)/len(zr_a)
print(f":NB {nb:.3f} :ZeroR {zr:.3f}")
assert nb > zr, f"NB ({nb:.3f}) <= ZeroR ({zr:.3f})"
print("ok test_classify")
def test_stats(*argv):
"""same / bestRanks / confused."""
assert same([10,20,30,40,50], [11,19,31,39,51], eps=0.5)
assert not same([1,2,3,4,5], [100,200,300,400,500], eps=0.5)
best = bestRanks({"good":[1,2,3], "bad":[100,200,300]})
assert "good" in best and "bad" not in best
out = confused({"a":{"a":80,"b":20}, "b":{"a":10,"b":90}})
for s in out:
print(f" :{s.label.strip()} acc={s.acc}")
assert 0 <= s.acc <= 100
print("ok test_stats")
def test_textmine(*argv):
"""CNB on processed text + tokenize/tf-idf on raw text. (slow)"""
f = need(argv[0]) if argv else need(EGCNB)
if f:
data = Data(csv(f)); ws = cnb(data); assert len(ws) >= 2
tmRandom(f)
g = need(EGTXT)
if g:
p = tmPrepare(g); assert len(p.top) >= 20
print("ok test_textmine")
# ============================================================
# Dispatcher: --<name> runs test_<name>; --key=val sets config
# ============================================================
def runTests(which="all"):
"""Run test_* funcs. which = all | fast (skip SLOW) | slow (only SLOW)."""
fails = 0
for name in sorted(globals()):
if not name.startswith("test_"): continue
base = name[len("test_"):]
if which == "fast" and base in SLOW: continue
if which == "slow" and base not in SLOW: continue
print(f"--- {name} ---")
try: globals()[name]()
except Exception: fails += 1; traceback.print_exc()
print(f"\nDone. fails={fails}")
if fails: sys.exit(1)
def list_cmds():
"""Print all commands (test_* funcs)."""
print("\nCommands (run via --<name> [FILE]):")
for name in sorted(globals()):
if name.startswith("test_"):
doc = (globals()[name].__doc__ or "").splitlines()[0]
mark = " *slow" if name[len("test_"):] in SLOW else ""
print(f" --{name[len('test_'):]:<12} {doc}{mark}")
def main():
cmd, rest = None, []
for a in sys.argv[1:]:
if a.startswith("--") and "=" in a: # config: --key=val
k, v = a[2:].split("=", 1); nest(the, k, thing(v))
elif a in ("-h", "--help"): print(__doc__); list_cmds(); return
elif a == "--list": list_cmds(); return
elif a in ("--all", "--fast", "--slow"):
random.seed(the.seed); runTests(a[2:]); return
elif a.startswith("--"): cmd = a[2:] # command: --name
else: rest.append(a) # args for the command
if not cmd:
print(__doc__); list_cmds(); return
fn = globals().get(f"test_{cmd}")
if not fn:
print(f"unknown: --{cmd}"); list_cmds(); sys.exit(1)
random.seed(the.seed)
fn(*rest)
if __name__ == "__main__":
main()
#!/usr/bin/env python3 -B
"""
dtlz4.py: drive ezr2 with an EXTERNAL MODEL instead of a CSV.
1. build a data file of random x-values with "?" for the goals;
2. redefine ezr2.labelled() so a row's goals come from a model;
3. optimize, labelling at most --budget rows.
python3 dtlz4.py
"""
import random, ezr2
from math import cos, sin, pi
N = 6 # decision vars x1..x6 (>4); 2 goals
def dtlz4(x):
"The model: x in [0,1]^N -> two objectives to MINIMIZE."
g = sum((v - 0.5) ** 2 for v in x[1:])
th = x[0] ** 100 * pi / 2 # 100 = DTLZ4's sampling bias
return [(1 + g) * cos(th), (1 + g) * sin(th)]
# Header roles (see ezr2's DATA note): X* are numeric inputs, F*-
# are goals to minimize. Each row is random x with goals unmeasured.
names = [f"X{i+1}" for i in range(N)] + ["F1-", "F2-"]
def pool(n=1000):
return [[random.random() for _ in range(N)] + ["?", "?"] for _ in range(n)]
def labelled(row):
"ezr2's seam: fill a row's goals from the model, and fold them into"
"data.cols so disty can normalize objectives as labels arrive."
if "?" in row[N:]:
row[N:] = dtlz4(row[:N])
for at in data.y: data.cols[at] = ezr2.add(data.cols[at], row[at])
return row
ezr2.labelled = labelled # labelled() reads the global `data`
def instance(row):
# Show one labelled row: its x (the decision) and goals. disty of an
# already-labelled row costs no new model runs, so we stay in budget
# (we avoid ezr2.wins(), which would label the whole pool).
print(" x " + " ".join("%.2f" % v for v in row[:N]))
print(" f " + " ".join("%.3f" % v for v in row[N:]) +
" (disty %.3f, lower=better)" % ezr2.disty(data, row))
ezr2.the.budget = 30
# (1) THE BEST INSTANCE: landscape() ranks the whole pool, returns the
# best rows it found within the label budget.
random.seed(1); data = ezr2.Data([names] + pool())
got = ezr2.landscape(data)
print("the best option found (one instance):")
instance(got[0])
# (2) AN EXPLANATORY MODEL: a tree saying which x-ranges win. The win
# column is relative only to the rows we labelled (so, in budget).
print("\nwhy? an explanatory model -- which x-ranges reach good goals:")
ezr2.show(data, ezr2.tree(data, got))
# (3) TEST THE MODEL ON NEW DATA: holdout() learns the tree on a train
# split, then uses it to pick the best row from an UNSEEN test split.
random.seed(1); data = ezr2.Data([names] + pool())
print("\ndoes that model generalize? best pick on unseen test data:")
instance(ezr2.holdout(data))
#!/usr/bin/env python3 -B
# ezr.py: explainable multi-objective optimization
# (c) 2026 Tim Menzies, timm@ieee.org, MIT license
"""
Options:
--seed=1 random number seed
--p=2 distance (1,2=Man,Euclid)
--learn.leaf=3 examples per leaf
--learn.budget=50 rows to evaluate
--learn.check=5 guesses to check
--learn.start=4 initial labels
--bayes.m=2 m-estimate for Naive Bayes
--bayes.k=1 k-estimate (Laplace) for NB
--few=128 max unlabelled rows
--stats.cliffs=0.195 Cliff's Delta threshold
--stats.conf=1.36 KS test confidence
--stats.eps=0.35 margin of error multiplier
--show.show=30 tree display width
--show.decimals=2 decimal places for floats
--textmine.norm=0 CNB weight normalization
--textmine.yes=20 positive samples
--textmine.no=20 negative samples
--textmine.top=100 top TF-IDF features
--textmine.valid=20 repeats for stats testing
"""
from __future__ import annotations
from time import perf_counter_ns as now
import os, re, random, sys, bisect, math, statistics
from collections import defaultdict
from pathlib import Path
from random import random as rand
from random import choices, choice, sample, shuffle
from math import log, log2, exp, sqrt, pi
from typing import Any, Iterable, Callable
from types import SimpleNamespace as S
isa = isinstance
# ___
# | ._ _ _
# | \/ |_) (/_ _>
# / |
type Qty = int|float
type Atom = str|bool|Qty
type Row = list[Atom]
type Rows = list[Row]
type Col = "Num|Sym"
type Cols = "list[Col]"
type Datas = "list[Data]"
# _
# / _ | ._ _ ._ _
# \_ (_) | |_| | | | | | _>
def Col(txt="", a=0):
"""Num or Sym column based on name case."""
return (Num if txt[0].isupper() else Sym)(txt, a)
class Num:
"""Summarizes a stream of numbers."""
def __init__(i, txt="", a=0):
i.txt, i.at, i.n = txt, a, 0
i.mu=i.m2=i.sd=0; i.heaven=txt[-1:]!="-"
class Sym:
"""Summarizes a stream of symbols."""
def __init__(i, txt="", a=0):
i.txt, i.at, i.n, i.has = txt, a, 0, {}
def mid(col):
"""Central tendency (mean or mode)."""
return col.mu if Num==type(col) else mode(col.has)
def mode(dct):
"""Return the key with most value."""
return max(dct, key=dct.get)
def spread(col):
"""Variability (sd or entropy)."""
return col.sd if Num==type(col) else entropy(col.has)
def entropy(dct):
"""Return diversity of some symbol counts."""
n = sum(dct.values())
return -sum(v/n*log2(v/n) for v in dct.values())
def norm(num, v):
"""Normalize via logistic function."""
if v == "?": return v
z = max(-3, min(3, (v - num.mu)/(num.sd + 1e-32)))
return 1/(1 + exp(-1.7*z))
# _
# | \ _. _|_ _.
# |_/ (_| |_ (_|
class Data:
"""Rows + summarized columns."""
def __init__(i, src=None):
src = iter(src or [])
i.rows, i._centroid = [], None
i.cols = Cols(next(src))
adds(src, i)
class Cols:
"""Organize Num/Sym columns from headers."""
def __init__(i, names):
i.names = names
i.klass, i.xs, i.ys, i.all = None, [], [], []
for j, txt in enumerate(names):
i.all.append(col := Col(txt, j))
if txt[-1] != "X":
if txt[-1] == "!": i.klass = col
role = i.ys if txt[-1] in "+-!" else i.xs
role.append(col)
def clone(data, rows=None):
"""Clone structure, optionally add rows."""
return adds(rows or [], Data([data.cols.names]))
def sub(it, v):
"""Remove value/row (add with w=-1)."""
return add(it, v, w=-1)
def add(it, v, w=1):
"""Add value/row to Data, Cols, Num, Sym."""
if Data is type(it):
it._centroid = None
add(it.cols, v, w)
if w > 0: it.rows.append(v)
else : it.rows.remove(v)
elif Cols is type(it):
[add(col, v[col.at], w) for col in it.all]
elif v != "?":
if Sym == type(it):
it.n += w
it.has[v] = w + it.has.get(v, 0)
elif w < 0 and it.n <= 2:
it.n = it.mu = it.m2 = it.sd = 0
else:
it.n += w
delta = v - it.mu
it.mu += w * delta / it.n
it.m2 += w * delta * (v - it.mu)
it.sd = sqrt(max(0, it.m2)/(it.n-1)) if it.n > 1 else 0
return v
def mids(data):
"""Centroid of all columns."""
data._centroid = data._centroid or [
mid(col) for col in data.cols.all]
return data._centroid
def adds(src, it=None):
"""Add multiple items to target."""
it = it or Num()
[add(it, v) for v in (src or [])]
return it
# _
# | \ o _ _|_ _. ._ _ _
# |_/ | _> |_ (_| | | (_ (/_
def minkowski(items, p=2):
"""Minkowski distance."""
tot, n = 0, 1e-32
for item in items: tot, n = tot + item**p, n + 1
return (tot/n) ** (1/p)
def disty(data, row):
"""Distance to heaven on Y vars."""
return minkowski((abs(norm(y, row[y.at]) - y.heaven)
for y in data.cols.ys), the.p)
def distx(data, r1, r2):
"""Distance between rows on X vars."""
return minkowski((aha(x, r1[x.at], r2[x.at])
for x in data.cols.xs), the.p)
def aha(col, u, v):
"""Distance between two values."""
if u == v == "?": return 1
if Sym == type(col): return u != v
u, v = norm(col, u), norm(col, v)
u = u if u != "?" else (0 if v > 0.5 else 1)
v = v if v != "?" else (0 if u > 0.5 else 1)
return abs(u - v)
def nearest(data, row, rows=None):
"""Closest row on x-columns."""
return min(rows or data.rows,
key=lambda r2: distx(data, row, r2))
def wins(data):
"""Score rows by distance to heaven.
Clamp d2h within lo+0.35*sd to lo."""
ys = sorted(disty(data, row) for row in data.rows)
ten = len(ys)//10
lo, med, sd = ys[0], ys[5*ten], (ys[9*ten] - ys[ten])/2.56
def f(row):
x = disty(data, row)
if x < lo + 0.35*sd: x = lo
return max(-100, int(100*(1 - (x-lo)/(med-lo + 1e-32))))
return f
# _
# |_) _. _ _
# |_) (_| \/ (/_ _>
# /
def like(col, v, prior):
"""How much a column likes a value."""
if type(col) == Sym:
return (col.has.get(v, 0) +
the.bayes.k * prior) / (col.n + the.bayes.k)
sd = col.sd + 1e-32; z = 2 * sd * sd
return exp(-(v - col.mu)**2 / z) / sqrt(pi * z)
def likes(data, row, n_rows, n_klasses):
"""Log likelihood of row given data."""
prior = (len(data.rows) + the.bayes.m
) / (n_rows + the.bayes.m * n_klasses)
ls = [like(col, v, prior) for col in data.cols.xs
if (v := row[col.at]) != "?"]
return log(prior) + sum(log(v) for v in ls if v > 0)
# _
# / ` ._ _ ._ _ ._ _ ._ ._ o ._ _
# \_, | | (_) | | (_) | | (/_ |_) | o | | | (_|
# | _|
def picks(data, row, n=1):
"""Mutate n random x-columns."""
s = row[:]
for col in sample(data.cols.xs,
min(n, len(data.cols.xs))):
s[col.at] = pick(col, s[col.at])
return s
def pick(it, v=None):
"""Sample from distribution."""
if Sym == type(it): return pick(it.has)
if Num == type(it):
tmp = v if v is not None and v != "?" else it.mu
lo, hi = it.mu - 3*it.sd, it.mu + 3*it.sd
new = tmp + it.sd * 2 * (rand() + rand() + rand() - 1.5)
return lo + (new - lo) % (hi - lo + 1e-32)
if dict == type(it):
n = sum(it.values()) * rand()
for k, v in it.items():
if (n := n - v) <= 0: break
return k
def extrapolate(cols, a, b, c, F=0.5):
"""DE blend over given cols: new = a + F*(b-c).
Num: arithmetic clipped to mu+/-4sd. Sym: prob-F pick of b else a. ?: take a."""
out = a[:]
for col in cols:
va, vb, vc = a[col.at], b[col.at], c[col.at]
if va == "?":
out[col.at] = "?"
elif Num == type(col):
if vb == "?" or vc == "?":
out[col.at] = va
else:
v = va + F * (vb - vc)
lo, hi = col.mu - 4*col.sd, col.mu + 4*col.sd
out[col.at] = max(lo, min(hi, v))
else:
out[col.at] = vb if (vb != "?" and rand() < F) else va
return out
# _
# |_ ._ _ _. _|_
# | | (_) | (_| |_
def o(x):
"""Recursive format. Sorts dicts."""
if isa(x, float):
return f"{x:.{the.show.decimals}f}"
if isa(x, dict):
return "{" + ", ".join(f"{k}={o(v)}"
for k, v in sorted(x.items())) + "}"
if isa(x, list):
return "{" + ", ".join(map(o, x)) + "}"
if isa(x, S): return "S" + o(x.__dict__)
if hasattr(x, "__dict__"):
return x.__class__.__name__ + o(x.__dict__)
return str(x)
def table(lst, w=10):
"""Print list of dicts as aligned table."""
if not lst: return
ds = [x if type(x) is dict else x.__dict__ for x in lst]
ks = list(ds[0].keys())
print("".join(f"{str(k):>{w}}" for k in ks))
print("-" * (len(ks) * w))
for d in ds:
print("".join(f"{str(d.get(k, '')):>{w}}" for k in ks))
def thing(txt):
"""Coerce string to number or bool."""
def bool(s): return {"true": 1, "false": 0}.get(s.lower(), s)
txt = txt.strip()
for f in [int, float, bool]:
try: return f(txt)
except ValueError: pass
def nest(t, k, v):
"""Set value in nested namespace."""
for x in (ks := k.split("."))[:-1]:
t = t.__dict__.setdefault(x, S())
setattr(t, ks[-1], v)
def csv(f, clean=lambda txt: txt.partition("#")[0].split(",")):
"""Yield typed rows from a CSV file."""
with open(f, encoding="utf-8") as file:
for txt in file:
row = clean(txt)
if any(x.strip() for x in row):
yield [thing(x) for x in row]
# _
# (_ _|_ _. _|_ _
# __) |_ (_| |_ _>
def same(xs, ys, eps):
"""Are two lists statistically same?"""
xs, ys = sorted(xs), sorted(ys)
n, m = len(xs), len(ys)
if abs(xs[n//2] - ys[m//2]) <= eps: return True
gt = sum(bisect.bisect_left(ys, a) for a in xs)
lt = sum(m - bisect.bisect_right(ys, a) for a in xs)
if abs(gt - lt) / (n*m) > the.stats.cliffs:
return False
ks = lambda v: abs(bisect.bisect_right(xs, v)/n
- bisect.bisect_right(ys, v)/m)
return max(max(map(ks, xs)), max(map(ks, ys))) <= \
the.stats.conf * ((n+m)/(n*m))**.5
def bestRanks(d):
"""Group treatments tied for best."""
items = sorted(d.items(), key=lambda kv:
sorted(kv[1])[len(kv[1])//2])
k0, lst0 = items[0]
best = {k0: adds(lst0, Num(k0))}
for k, lst in items[1:]:
if same(lst0, lst, spread(best[k0]) * the.stats.eps):
best[k] = adds(lst, Num(k))
else: break
return best
def confused(cf):
"""Confusion stats per class. All metrics as int %."""
klasses = sorted(set(cf.keys()).union(
{g for w in cf.values() for g in w.keys()}))
total = sum(cf[w][g] for w in cf for g in cf[w])
p = lambda y, z: int(100 * y / (z or 1e-32))
out = []
for c in klasses:
tp = cf.get(c, {}).get(c, 0)
fn = sum(cf.get(c, {}).values()) - tp
fp = sum(cf.get(w, {}).get(c, 0) for w in cf if w != c)
tn = total - tp - fn - fp
pd, pr = p(tp, tp+fn), p(tp, fp+tp)
sp = p(tn, tn+fp)
out.append(S(tp=tp, fn=fn, fp=fp, tn=tn,
pd=pd, pr=pr,
f1=int(2*pd*pr/(pd+pr+1e-32)),
g=int(2*pd*sp/(pd+sp+1e-32)),
acc=p(tp+tn, total), label=" "+c))
return out
# ___
# | ._ _ _
# | | (/_ (/_
class Tree:
"""Decision tree node."""
def __init__(i, data, rows, klass=None, y=Num):
klass = klass or (lambda r: disty(data, r))
i.d = clone(data, rows)
i.ynum = adds((klass(row) for row in rows), y())
i.col, i.cut = None, 0
i.left = i.right = None
def treeCuts(col, rows):
"""Possible split points for a column."""
if Sym == type(col): return list(col.has.keys())
vs = [row[col.at] for row in rows if row[col.at] != "?"]
return [sorted(vs)[len(vs)//2]] if vs else []
def treeSplit(data, col, cut, rows, klass=None, y=Num):
"""Evaluate split on col at cut."""
klass = klass or (lambda r: disty(data, r))
l_rows, r_rows, l_y, r_y = [], [], y(), y()
for row in rows:
v = row[col.at]
go = v == "?" or (v == cut if Sym == type(col) else v <= cut)
(l_rows if go else r_rows).append(row)
add(l_y if go else r_y, klass(row))
s = l_y.n * spread(l_y) + r_y.n * spread(r_y)
return s, col, cut, l_rows, r_rows
def treeGrow(data, rows, klass=None, y=Num):
"""Grow tree to minimize Y-variance (or entropy if y=Sym)."""
tree = Tree(data, rows, klass, y)
if len(rows) >= 2 * the.learn.leaf:
splits = (treeSplit(data, col, cut, rows, klass, y)
for col in tree.d.cols.xs
for cut in treeCuts(col, rows))
if valid := [s for s in splits
if min(len(s[3]), len(s[4])) >= the.learn.leaf]:
_, tree.col, tree.cut, left, right = min(
valid, key=lambda x: x[0])
tree.left = treeGrow(data, left, klass, y)
tree.right = treeGrow(data, right, klass, y)
return tree
def treeLeaf(tree, row):
"""Find leaf node for row."""
if not tree.left: return tree
v = row[tree.col.at]
go = v != "?" and (v <= tree.cut if Num == type(tree.col) else v == tree.cut)
return treeLeaf(tree.left if go else tree.right, row)
def treeNodes(tree, lvl=0, col=None, op="", cut=None):
"""Yield all nodes (depth-first)."""
yield tree, lvl, col, op, cut
if tree.col:
ops = ("<=", ">") if Num == type(tree.col) else ("==", "!=")
kids = sorted([(tree.left, ops[0]), (tree.right, ops[1])],
key=lambda z: mid(z[0].ynum))
for k, txt in kids:
if k: yield from treeNodes(k, lvl+1, tree.col, txt, tree.cut)
def treeShow(tree):
"""Print tree structure."""
for t1, lvl, col, op, cut in treeNodes(tree):
p = f"{col.txt} {op} {o(cut)}" if col else ""
if lvl > 0: p = "| " * (lvl-1) + p
g = {col.txt: mid(col) for col in t1.d.cols.ys}
print(f"{p:<{the.show.show}}"
f",{o(mid(t1.ynum)):>4}"
f" ,({t1.ynum.n:3}), {o(g)}")
def treePlan(tree, here):
"""Plans to improve from current leaf."""
eps = the.stats.eps * spread(tree.ynum)
for there, _, _, _, _ in treeNodes(tree):
if there.col is None and \
(dy := mid(here.ynum) - mid(there.ynum)) > eps:
diff = [f"{col.txt}={o(mid(col))}"
for col, h in zip(there.d.cols.xs, here.d.cols.xs)
if mid(col) != mid(h)]
if diff:
yield dy, mid(there.ynum), diff
# _
# / ` | _ _|_ _ ._
# \_, | |_| _> |_ (/_ |
def kmeans(d, rs=None, k=10, n=10, cents=None) -> Datas:
"""Cluster rows into k groups."""
rs, out = rs or d.rows, []
cents = cents or choices(rs, k=k)
for _ in range(n):
out = [clone(d) for _ in cents]
for r in rs:
add(out[min(range(len(cents)),
key=lambda j: distx(d, cents[j], r))], r)
cents = [mids(kid) for kid in out if kid.rows]
return out
def kpp(d, rs=None, k=10, few=256) -> Rows:
"""k-means++ centroid selection."""
rs = rs or d.rows
out = [choice(rs)]
while len(out) < k:
t = sample(rs, min(few, len(rs)))
ws = {i: min(distx(d, t[i], c)**2 for c in out)
for i in range(len(t))}
out.append(t[pick(ws)])
return out
def half(d, rs, few=20) -> tuple:
"""Divide rows by two extreme points."""
t = sample(rs, min(few, len(rs)))
gap, east, west = max(
((distx(d, r1, r2), r1, r2)
for r1 in t for r2 in t),
key=lambda z: z[0])
proj = lambda r: (
distx(d, r, east)**2 + gap**2 -
distx(d, r, west)**2) / (2*gap + 1e-32)
rs = sorted(rs, key=proj)
n = len(rs) // 2
return (rs[:n], rs[n:], east, west, gap, proj(rs[n]))
def rhalf(d, rs=None, k=10, stop=None, few=20) -> Datas:
"""Recursively halve into clusters."""
rs = rs if rs is not None else d.rows
stop = stop or 20
if len(rs) <= 2*stop:
return [clone(d, rs)]
l, r, east, west, gap, cut = half(d, rs, few)
return rhalf(d, l, k, stop, few) + rhalf(d, r, k, stop, few)
def neighbors(d, r1, ds, near=1, fast=False) -> Rows:
"""Find nearest rows or centroid."""
c = min(ds, key=lambda c: distx(d, r1, mids(c)))
return ([mids(c)] if fast
else sorted(c.rows, key=lambda r2: distx(d, r1, r2))[:near])
# _
# / ` | _. _ _ o __
# \_, | (_| _> _> | | y
def classify(src, wait=10):
"""Incremental NB: test then train."""
src = iter(src)
h, cf, all = {}, None, Data([next(src)])
for n, row in enumerate(src):
want = row[all.cols.klass.at]
if n >= wait:
cf = _dinc(want,
max(h, key=lambda kl: likes(h[kl], row, len(all.rows), len(h))),
cf)
if want not in h: h[want] = clone(all)
add(all, add(h[want], row))
return cf
def _dinc(k1, k2, b4=None):
"""Increment nested dict counter."""
b4 = b4 or {}; b4[k1] = b4.get(k1) or {}
b4[k1][k2] = b4[k1].get(k2, 0) + 1
return b4
# _
# (_ _ _. ._ _ |_
# __) (/_ (_| | (_ | |
def last(gen) -> Any:
"""Final value from generator."""
v = None
for v in gen: pass
return v
def oracleNearest(data, row):
"""Score: copy y-vals from nearest known row."""
near = nearest(data, row)
for col in data.cols.ys:
row[col.at] = near[col.at]
return disty(data, row)
def oneplus1(data, mutate, accept, oracle, budget=1000, restart=0):
"""(1+1) search: mutate, score, accept."""
h, best, best_e = 0, None, 1E32
s, e, imp = choice(data.rows)[:], 1E32, 0
while h < budget:
for sn in mutate(s):
h += 1
en = oracle(sn)
if accept(e, en, h, budget):
s, e = sn, en
if en < best_e:
best, best_e, imp = sn[:], en, h
yield h, best_e, best
if restart and h - imp > restart:
s = choice(data.rows)[:]
e, imp = 1E32, h
break
def sa(d, oracle, restarts=0, m=0.5, budget=1000):
"""Simulated annealing."""
n = max(1, int(m * len(d.cols.xs)))
def accept(e, en, h, b):
return en < e or rand() < exp((e - en) / (1 - h/b + 1E-32))
def mutate(s): yield picks(d, s, n)
return oneplus1(d, mutate, accept, oracle, budget, restarts)
def ls(d, oracle, restarts=100, p=0.5, tries=20, budget=1000):
"""Local search."""
def accept(e, en, *_): return en < e
def mutate(s):
c = choice(d.cols.xs)
for _ in range(tries if rand() < p else 1):
s = s[:]
s[c.at] = pick(c, s[c.at])
yield s
return oneplus1(d, mutate, accept, oracle, budget, restarts)
def de(data, oracle, budget=1000, NP=30, F=0.5):
"""Differential evolution (DE/rand/1). Population NP, blend F.
Yields (evals, best_energy, best_row) on each improvement."""
pop = [r[:] for r in sample(data.rows, min(NP, len(data.rows)))]
es = [oracle(r) for r in pop]
h = len(pop)
best_i = min(range(len(pop)), key=lambda j: es[j])
yield h, es[best_i], pop[best_i][:]
while h < budget:
for i in range(len(pop)):
if h >= budget: break
a_i, b_i, c_i = sample([j for j in range(len(pop)) if j != i], 3)
trial = extrapolate(data.cols.xs, pop[a_i], pop[b_i], pop[c_i], F)
en = oracle(trial); h += 1
if en < es[i]:
pop[i], es[i] = trial, en
if en < es[best_i]:
best_i = i
yield h, en, trial[:]
# _
# /\ _ _ (_ o ._ _
# /--\ (_ (_| __)|_| | | (/_
# _|
def acquireWithBayes(data, best, rest, row):
"""Score: rest - best likelihood."""
n = len(best.rows) + len(rest.rows)
return likes(rest, row, n, 2) - likes(best, row, n, 2)
def acquireWithCentroid(data, best, rest, row):
"""Score: dist(best) - dist(rest)."""
return (distx(data, row, mids(best)) -
distx(data, row, mids(rest)))
def warm_start(data, rows, label):
"""Init lab/best/rest from start rows."""
lab = clone(data, rows[:the.learn.start])
lab.rows.sort(key=lambda row: disty(lab, label(data, row)))
n = int(sqrt(len(lab.rows)))
return (lab,
clone(data, lab.rows[:n]),
clone(data, lab.rows[n:]),
rows[the.learn.start:])
def rebalance(best, rest, lab):
"""Cap best at sqrt(|lab|); evict worst."""
if len(best.rows) > sqrt(len(lab.rows)):
best.rows.sort(key=lambda row: disty(lab, row))
rest.rows.append(
add(rest.cols, sub(best.cols, best.rows.pop())))
def acquire(data, score=acquireWithCentroid,
label=lambda _, row: row):
"""Active learning. Returns labeled Data."""
rows = data.rows[:]
shuffle(rows)
lab, best, rest, unlab = warm_start(data, rows[:the.few], label)
fn = lambda row: score(lab, best, rest, row)
for _ in range(the.learn.budget):
if not unlab: break
pickr, *unlab = sorted(unlab, key=fn)
add(lab, add(best, label(data, pickr)))
rebalance(best, rest, lab)
lab.rows.sort(key=lambda r: disty(lab, r))
return lab
def holdoutWin(data, repeats=20):
"""Active-learning quality. Each rep: split rows in half; acquire labels
on one half; grow a tree from those labels; use the tree to sort the
held-out half; check the top `learn.check`; keep the best one's win.
Data is loaded once; return the mean win over `repeats` shuffles."""
win, out = wins(data), Num()
for _ in range(repeats):
rows = data.rows[:]; shuffle(rows)
half = len(rows) // 2
train, holdout = rows[:half], rows[half:]
lab = acquire(clone(data, train)) # acquire on one half
tree = treeGrow(lab, lab.rows) # tree from the labels
ranked = sorted(holdout, key=lambda r: treeLeaf(tree, r).ynum.mu) # tree sorts other half
best = min(ranked[:the.learn.check], key=lambda r: disty(data, r)) # first `check` -> best
add(out, win(best))
return mid(out)
# ___ ._ _
# | _ _|_ ._ _ | | | o ._ _
# | (/_ |_ >< |_ | | | | | | (/_
_TM_DIR = Path(__file__).parent
def _tm_load(pkg: str) -> set:
"""Load newline-separated words from resource file."""
try: s = (_TM_DIR / pkg).read_text()
except Exception: s = ""
return {w.strip().lower() for w in s.splitlines() if w.strip()}
def _tm_stem1(w: str, sufs: list, cache: dict, n: int = 1) -> str:
"""Recursively strip known suffixes, caching results."""
if w in cache or n <= 0: return cache.setdefault(w, w)
for s in sufs:
if w.endswith(s) and len(w) > len(s) + 2:
c = w[:-len(s)]
if len(c) >= 2 and len(c) >= len(w) * .5:
return cache.setdefault(w, _tm_stem1(c, sufs, cache, n - 1))
return cache.setdefault(w, w)
def _tm_cells(s: str) -> list:
"""Split CSV line on commas, respecting quoted fields."""
r, c, q = [], [], 0
for ch in s:
if ch == '"' and (not c or q): q ^= 1
elif q < 1 and ch == ',': r += [''.join(c)]; c = []
else: c += [ch]
return r + [''.join(c)]
def tmCsv(f: str) -> Iterable:
"""Yield typed rows from quote-aware CSV."""
with open(f, encoding="utf-8") as fh:
for s in fh:
r = _tm_cells(s)
if any(x.strip() for x in r):
yield [thing(x.strip()) for x in r]
def tmPrepare(f: str) -> S:
"""Full text-mining pipeline."""
return tmTfidf(tmStem(tmNostop(tmTokenize(f))))
def tmTokenize(f: str, txt: str = "abstract", klass: str = "label") -> S:
"""Parse CSV, extract lowercase words of length > 2."""
p = S(docs=[], tf=[], df={}, tfidf={}, top=[])
rows = tmCsv(f); hdr = next(rows)
assert txt in hdr, f"need '{txt}' col (raw CSV?)"
t, k = hdr.index(txt), hdr.index(klass)
for r in rows:
ws = [w for w in re.findall(r'\b[a-zA-Z]+\b',
str(r[t]).lower()) if len(w) > 2]
p.docs.append(S(words=ws, klass=str(r[k])))
return p
def tmNostop(p: S) -> S:
"""Remove stop words using resources/text/stop_words.txt."""
s = _tm_load("resources/text/stop_words.txt")
for d in p.docs: d.words = [w for w in d.words if w not in s]
return p
def tmStem(p: S) -> S:
"""Suffix-based stemming using resources/text/suffixes.txt."""
sufs = sorted(_tm_load("resources/text/suffixes.txt"), key=len, reverse=True)
cache = {}
for d in p.docs: d.words = [_tm_stem1(w, sufs, cache) for w in d.words]
return p
def tmTfidf(p: S) -> S:
"""Compute TF-IDF, keep top the.textmine.top features."""
for d in p.docs:
c = {}
for t in d.words: c[t] = c.get(t, 0) + 1
for t in c: p.df[t] = p.df.get(t, 0) + 1
p.tf.append(c)
N = len(p.docs) or 1
ws = sorted([(w, sum(c.get(w, 0) * log(N / df)
for c in p.tf if w in c))
for w, df in p.df.items()],
key=lambda x: x[1], reverse=True)[:the.textmine.top]
p.top, p.tfidf = ws, {w: s for w, s in ws}
return p
def tmData(p: S) -> Data:
"""Convert preprocessed namespace into a Data."""
ws = list(p.tfidf) or sorted({w for c in p.tf for w in c})[:the.textmine.top]
return Data(
[[w.capitalize() for w in ws] + ["klass!"]]
+ [[tf.get(w, 0) for w in ws] + [d.klass]
for tf, d in zip(p.tf, p.docs)])
def cnb(data: Data, rows: Rows = None, alpha: float = 1.0) -> dict:
"""Train complement naive Bayes weights."""
rows = rows or data.rows
key = data.cols.klass.at
freq = defaultdict(lambda: defaultdict(float))
total, klasses = defaultdict(float), set()
for r in rows:
k = r[key]; klasses.add(k)
for c in data.cols.xs:
at = c.at
v = r[at] if r[at] != "?" else 0
freq[k][at] += v; total[at] += v
T, n, ws = sum(total.values()), len(data.cols.xs), {}
for k in klasses:
den = T + n * alpha - sum(freq[k].values()) + 1e-32
ws[k] = {a: -log((total[a] + alpha - freq[k].get(a, 0) + 1e-32) / den)
for a in total}
if the.textmine.norm:
ws = {k: {a: v / (sum(abs(x) for x in w.values()) or 1e-32)
for a, v in w.items()}
for k, w in ws.items()}
return ws
def cnbLike(ws: dict, at: int, row: Row, k: str) -> float:
"""Single column's contribution to class k."""
v = row[at] if row[at] != "?" else 0
return v * ws[k].get(at, 0)
def cnbLikes(ws: dict, data: Data, row: Row, k: str) -> float:
"""Sum CNB scores across x-columns for row and class."""
return sum(cnbLike(ws, c.at, row, k) for c in data.cols.xs)
def _tm_setup(src: Any) -> tuple:
"""Build Data, collect positive indices + full index set."""
data = Data(csv(src)) if isinstance(src, str) else tmData(src)
key = data.cols.klass.at
pos = [i for i, r in enumerate(data.rows) if r[key] == "yes"]
return data, key, pos, set(range(len(data.rows)))
def _tm_best(ws: dict, data: Data, r: Row) -> str:
"""Class with highest CNB score for row."""
return max(ws, key=lambda k: cnbLikes(ws, data, r, k))
def _tm_recall(ws: dict, data: Data, key: int) -> int:
"""Percent of positives correctly predicted."""
ps = [r for r in data.rows if r[key] == "yes"]
if not ps: return 0
return int(100 * sum(_tm_best(ws, data, r) == "yes" for r in ps) / len(ps))
def _tm_iqr(vs: list) -> float:
"""Interquartile range."""
qs = statistics.quantiles(vs, n=4); return qs[2] - qs[0]
def _tm_warm(pos: list, idx: set) -> set:
"""Warm-start label set: yes positives + no random negatives."""
ti = random.sample(pos, min(the.textmine.yes, len(pos)))
rest = list(idx - set(ti))
return set(ti + random.sample(rest, min(the.textmine.no, len(rest))))
def tmRandom(src: Any) -> bool:
"""Repeated random warm-start CNB. Print median recall + IQR."""
data, key, pos, idx = _tm_setup(src)
out = [_tm_recall(cnb(data, [data.rows[i] for i in _tm_warm(pos, idx)]), data, key)
for _ in range(the.textmine.valid)]
md = statistics.median(out)
print(f"Random {the.textmine.yes}+/{the.textmine.no}-: "
f"pd={md} iqr={_tm_iqr(out) if len(out) > 1 else 0}")
return True
def tmActive(src: Any) -> bool:
"""Warm-start then greedily acquire row CNB ranks most yes."""
data, key, pos, idx = _tm_setup(src)
trails = []
for _ in range(the.textmine.valid):
lab = _tm_warm(pos, idx); pool = idx - lab; trail = []
while True:
ws = cnb(data, [data.rows[i] for i in lab])
trail.append(_tm_recall(ws, data, key))
if len(lab) >= the.learn.budget or not pool: break
pick_i = max(pool, key=lambda i:
cnbLikes(ws, data, data.rows[i], "yes"))
lab.add(pick_i); pool.discard(pick_i)
trails.append(trail)
n = min(len(t) for t in trails)
w0 = the.textmine.yes + the.textmine.no
print(f"\n{'=' * 40}\nActive CNB {the.textmine.valid}x "
f"warm={w0} B={the.learn.budget}\n{'=' * 40}")
rows = [["labeled", "pd", "iqr"]]
for s in range(n):
vs = [t[s] for t in trails]; md = statistics.median(vs)
rows.append([w0 + s, md, _tm_iqr(vs) if len(vs) > 1 else 0])
_tm_align(rows)
return True
def _tm_align(rows: list) -> None:
"""Print list-of-lists as right-aligned table."""
ws = [max(len(str(r[c])) for r in rows) for c in range(len(rows[0]))]
for r in rows:
print(" ".join(str(v).rjust(w) for v, w in zip(r, ws)))
# _ _
# |_) _ _. _| \/
# | \ (/_ (_| (_| /
the = S()
for k, v in re.findall(r"([\w.]+)=(\S+)", __doc__):
nest(the, k, thing(v))

ezr2: a tour

A textbook in genetic-stanza form. Read top-to-bottom: each concept appears in build order, atoms first, call sites last. Numbered traces ([1]>) are a live python3 -i ezr2.py session; outputs are verbatim.

AUTHOR-CONFIG
audience: Python dev, new to active learning
assumed:  recursion, dicts, basic stats
language: Python 3
depth:    terse
tone:     K&R
prose:    65 cols   code: 4-space   repl: [1]>

The whole idea: labels (a row's distance to its goals) are expensive. Spend few. disty is the only oracle; everything else is free arithmetic over the cheap x-columns.

Atoms: Num and Sym

A Num is a 3-tuple (n, mu, m2) — count, running mean, and sum of squared deviations. A Sym is just a dict of value counts. Two summaries, one numeric, one symbolic.

Sym = dict
def Num(n=0, mu=0, m2=0): return (n, mu, m2)

welford folds one value into a Num in a single pass; sd reads a standard deviation back out of m2. No stored list.

def welford(v, n, mu, m2):
  n += 1; d = v - mu; mu += d / n
  return (n, mu, m2 + d * (v - mu))

adds folds a stream into a Num. add dispatches on type: Num via welford, Sym via a count bump.

[1]> Num()
(0, 0, 0)
[2]> c = adds([2,4,4,4,5,5,7,9]); c
(8, 5.0, 32.0)
[3]> round(mu_(c),2), round(sd(c),2)
(5.0, 2.14)

Sibling spreads: sd for a Num, entropy for a Sym. Note adds can't seed a Sym — an empty dict is falsy, so i or Num() discards it. Build a Sym with add in a loop.

[4]> s = Sym()
     for v in "aabbbc": add(s,v)
     s
{'a': 2, 'b': 3, 'c': 1}
[5]> round(entropy(s),2)
1.46

Data: rows and roles

Data reads a CSV. The first row is column names; their suffixes assign roles. Upper = Num, lower = Sym. A goal ends + (maximize), - (minimize), or ! (klass). X skips; ~ marks a sensitive column.

def roles(data):
  for at, s in enumerate(data.names):
    data.cols[at] = Num() if s[0].isupper() else Sym()
    if s[-1] == "X": continue
    if s[-1] in "+-!":
      data.y += [at]; data.goal[at] = s[-1] == "+"
      if s[-1] == "!": data.klass = at
    else:
      data.x += [at]
      if s[-1] == "~": data.protect += [at]
  return data

So x are predictors, y are goals. goal[at] is True when bigger is better.

[6]> d = Data(csv(the.file))
     len(d.rows), d.names[:3]
(398, ['Clndrs', 'Volume', 'HpX'])
[7]> d.x[:4]
[0, 1, 3, 4]
[8]> d.y, [d.names[a] for a in d.y]
([5, 6, 7], ['Lbs-', 'Acc+', 'Mpg+'])
[9]> d.rows[0]
[8, 304, 193, 70, 1, 4732, 18.5, 10]

Distance: y-space and x-space

disty is the label: how far a row sits from the ideal goals, 0 = best. Each goal is normalized to 0..1, compared to its goal direction, then aggregated by a p-norm. The labelled hook is where a real evaluator would fill the row.

def disty(data, row, **kw):
  row = labelled(row)
  return minkowski(
    (abs(norm(data.cols[at], row[at]) - data.goal[at])
     for at in data.y if row[at] != "?"), **kw)

[10]> round(disty(d, d.rows[0]), 3)
0.786
[11]> best = min(d.rows, key=lambda r: disty(d,r))
      round(disty(d,best),3), best[:5]
(0.075, [4, 90, 48, 78, 2])

distx is its sibling over the x-columns — free to compute, no goals consulted. Active learning leans on this: cluster in x-space, spend labels sparingly in y-space.

[12]> round(distx(d, d.rows[0], best), 3)
0.785

Active learning: landscape

project maps rows onto an east-west line through two distant labelled poles (the y-better one is east). landscape then labels grow rows per round, keeps the promising fraction, and repeats until the budget (budget-check) is spent.

def landscape(data):
  x   = lambda a,b: distx(data, a, b)
  y   = lambda r: disty(data, r)
  cap = the.budget - the.check
  pool = shuffle(data.rows)
  lab  = {}
  while len(lab) < cap and len(pool) >= 2*the.leaf:
    here, k = [], 0
    for r in pool:
      if id(r) in lab: here.append(r)
      elif k < the.grow and len(lab) < cap:
        lab[id(r)] = r; here.append(r); k += 1
    n = max(1, int((1-the.keepf)*len(pool)))
    pool = sorted(pool, key=project(here, x, y))[n:]
  return sorted(lab.values(), key=y)

lab is keyed on id(row) (rows are mutable lists, so unhashable); its length is the budget. wins grades a row: % of the gap from median to best that it closes.

[13]> got = landscape(d)
      len(got), round(disty(d,got[0]),3)
(44, 0.087)
[14]> round(wins(d)(got[0]), 1)
97.3

44 labels land within ~1% of the best disty in the data — ~97% of the median-to-best gap closed.

Trees: cuts, tree, show

A cut splits rows to minimize impurity (a Num's m2, a Sym's entropy×count). cuts only yields splits leaving leaf rows on both sides — the size guard lives here, in the selector, so a degenerate one-row cut never wins min.

def cuts(data,rows,at,Y):
  xy  = [(r[at], Y(r)) for r in rows if r[at] != "?"]
  n   = len(xy)
  tot = adds(y for _,y in xy)
  cut = lambda l,k: (impurity(l)+impurity(mix(tot,l,-1)),at,k)
  big = lambda lo: the.leaf <= lo <= n-the.leaf
  ...

tree recurses on the lowest-cost cut. has routes a row (? goes yes-side); if yes and no guards the one case the selector can't — a ?-heavy column emptying a side.

def tree(data, rows, Y=None, lvl=0):
  Y = Y or (lambda r: disty(data, r))
  t = o(at=None, mu=mu_(adds(Y(r) for r in rows)),
        n=len(rows), rows=rows)
  if len(rows) >= 2*the.leaf and lvl < the.maxd:
    if cut := min((c for at in data.x
                   for c in cuts(data,rows,at,Y)),default=0):
      _, at, v = cut
      col = data.cols[at]
      yes, no = [], []
      for r in rows:
        (yes if has(r,col,at,v) else no).append(r)
      if yes and no:
        t.at, t.v = at, v
        t.yes = tree(data, yes, Y, lvl+1)
        t.no  = tree(data, no,  Y, lvl+1)
  return t

show prints it: a win column, leaf size n, the goal means, then the branch tests. +/- flag the best/worst leaf; subtrees sort best-first.

[15]> t = tree(d, landscape(d)); show(d, t)
  win     n    Lbs-   Acc+   Mpg+
    9    41  2386.0   16.4   28.5
   39    26  2083.0   16.4   33.1  Volume <= 116
   ...
+  78     3  2032.3   17.7   43.3  | ... | Volume <= 89
   ...
  -43    15  2911.0   16.4   20.7  Volume > 116
- -66     4  3368.5   16.1   20.0  |  Volume > 200

Low Volume + light cars sit at the good (+) leaf, Mpg 43.3; the heavy - leaf bottoms out at Mpg 20.0.

The budget rig: holdout

landscape searches all the data. holdout is the honest generalization test: split 50:50, landscape on the train half only, build a tree on those ~45 rows, then use it to rank the unseen test half and label the top check.

def holdout(data):
  rows  = shuffle(data.rows)
  mid   = len(rows)//2
  train, test = rows[:mid], rows[mid:]
  got   = landscape(clone(data, train))
  t     = tree(data, got)
  top   = sorted(test,
                 key=lambda r: leaf(data,t,r))[:the.check]
  return min(top, key=lambda r: disty(data,r))

clone(data, rows) is a fresh Data over the train subset, so landscape's pool is the train half. The total label cost is one budget — no peeking at test.

[16]> best = holdout(Data(csv(the.file)))
      round(disty(d2,best),3), round(wins(d2)(best),1)
(0.105, 93.3)

Plumbing

thing coerces a CSV cell to int/float/bool/str. csv yields rows as lists (so labelled can mutate them). settings parses the module docstring's --key ... = val lines into the — the options table is the config, no duplicate defaults to drift.

def settings(doc):
  pat = r"--(\w+)\s+[^=\n]*=\s*(\S+)"
  return o(**{k: thing(v)
              for k,v in re.findall(pat, doc)})

the = settings(__doc__)

main applies --key=val overrides, then runs any named test_*. Tests are bare names on the command line:

$ python3 ezr2.py landscapes --budget=80
$ python3 ezr2.py tree
$ pytest ezr2.py

That is the whole arc: cheap x-distance to steer, expensive y-distance to label, a tree to explain, a budget to keep everyone honest.

#!/usr/bin/env python3 -B
"""
ezr2: landscape analysis for xai and optimization CSV data.
(c) 2026, Tim Menzies <timm@ieee.org>, MIT license
USAGE: python3 ezr2.py [--key=val ...] [test ...]
OPTIONS: (defaults below are parsed into `the`):
--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
--keepf keep frac = 0.66
--round decimals shown = 3
--landscape active | random = active
-h print this help
TESTS: (run with their bare name):
disty rows by disty: top 5 / bottom 5
landscape 20 shuffles; best disty per run
landscapes one mean-win line (the sweep)
tree build+show a tree on acquired rows
holdout 50:50 split; tree picks best test row
holdouts holdout x20; land vs random verdict
pure no tree: best labelled, land vs random
same demo+validate the same() stat test
all run every test above, reseting seed each
"""
"""
INSTALL: grab this script and some sample data, then run a test:
wget -O ezr2.py http://tiny.cc/ezr#file-ezr2-py
wget -O auto93.csv http://tiny.cc/optimiz#file-misc_auto93-csv
python3 ezr2.py --file=auto93.csv disty
MODES: optimize a static CSV (format below), or a live model by
overriding labelled() to compute goals on demand -- worked example
in dtlz4.py (http://tiny.cc/ezr#file-dtlz4-py).
DATA: comma-separated, first row names the columns. A name's last
character sets that column's role; its first sets its type:
Upper case first letter -> numeric (else: symbolic)
+ / - suffix -> goal: maximize / minimize (a y-column)
! suffix -> klass (a y-column)
X suffix -> ignore this column
~ suffix -> protected x-column
(no suffix) -> ordinary x-column (input)
E.g. the auto93 header Clndrs,Volume,HpX,Model,origin,Lbs-,Acc+,Mpg+
has numeric inputs (Clndrs/Volume/Model), a symbolic input (origin),
an ignored column (HpX), and goals minimize Lbs, maximize Acc/Mpg.
DISTY: every row's "distance to heaven" -- its distance to the ideal
point where all goals are best (0 = ideal, 1 = worst). `disty` reads
only the y-columns, so optimization can score a row without seeing
how it was made. `python3 ezr2.py disty` sorts rows by disty and
prints the best 5, a blank line, then the worst 5:
Clndrs Volume HpX Model origin Lbs- Acc+ Mpg+ disty
4 90 48 78 2 1985 21.5 40 0.075
... ...
8 455 225 70 1 4425 10 10 0.954
Best rows (disty~0) are light, high-Mpg cars; worst (disty~1) are
heavy guzzlers. Optimizers seek low-disty rows while labelling
(inspecting the y of) as few rows as possible.
"""
import re, sys, random
from math import log2, exp
from bisect import bisect_left, bisect_right
from types import SimpleNamespace as o
isa = isinstance
BIG = 1e32
TINY = 1e-32
#-- Cols --------------------------------------------------------
Sym = dict
def Num(n=0, mu=0, m2=0): return (n, mu, m2)
def n_(num) : return num[0]
def mu_(num) : return num[1]
def m2_(num) : return num[2]
def welford(v, n, mu, m2):
"Fold value v into a Num; return new (n,mu,m2)."
n += 1; d = v - mu; mu += d / n
return (n, mu, m2 + d * (v - mu))
def sd(num): n,mu,m2 = num; return 0 if n<2 else (max(0,m2)/(n-1))**.5
def entropy(d):
"Shannon entropy of a Sym (a dict of counts)."
N = sum(d.values()) or 1
return -sum(v/N*log2(v/N) for v in d.values() if v)
def mix(i, j, inc=1):
"Merge two cols; inc=-1 subtracts j from i."
if isa(i, Sym):
return {k: i.get(k, 0) + inc * j.get(k, 0) for k in i | j}
(ni, mui, m2i), (nj, muj, m2j) = i, j
n = ni + inc * nj
if n <= 0: return Num()
d = muj - mui
mu = (ni * mui + inc * nj * muj) / n
m2 = m2i + inc * m2j + inc * d * d * ni * nj / n
return Num(n, mu, max(0, m2)) # subtraction can underflow m2 below 0
#-- Data --------------------------------------------------------
def Data(src):
"Build a table; first row = column names."
src = iter(src)
data = o(names=next(src), cols={}, x=[], y=[], goal={},
klass=None, protect=[], rows=[])
return adds(src, roles(data))
def clone(data, rows):
"Fresh Data over a subset of rows."
return Data([data.names] + rows)
def roles(data):
"Tag cols x/y/klass/protect from name suffixes."
for at, s in enumerate(data.names):
data.cols[at] = Num() if s[0].isupper() else Sym()
if s[-1] == "X": continue
if s[-1] in "+-!":
data.y += [at]; data.goal[at] = s[-1] == "+"
if s[-1] == "!": data.klass = at
else:
data.x += [at]
if s[-1] == "~": data.protect += [at]
return data
def adds(src, i=None):
"Fold a stream of values/rows into i (Num by default)."
i = Num() if i is None else i # keep an empty Sym; {} is falsy
for v in src: i = add(i,v)
return i
def add(i,v):
"Add one value to a col, or one row to a Data."
if isa(i,o):
for at,col in i.cols.items(): i.cols[at] = add(col,v[at])
i.rows += [v]
elif v != "?":
if isa(i,Sym): i[v] = i.get(v,0) + 1
else: i = welford(v, *i)
return i
#-- Dist --------------------------------------------------------
def mid(i): return max(i,key=i.get) if isa(i,Sym) else mu_(i)
def var(i): return entropy(i) if isa(i,Sym) else sd(i)
def norm(num, v):
"Map v to 0..1 via a logistic on its z-score."
if v == "?": return v
z = (v - mu_(num)) / (sd(num) + 1e-32)
return 1 / (1 + exp(-1.7 * max(-3, min(3, z))))
def minkowski(vals, p=2):
"Aggregate per-item distances via the p-norm."
tot = nn = 0
for v in vals: tot += v**p; nn += 1
return (tot / (nn or 1)) ** (1/p)
def gap(col, u, v):
"Distance 0..1 between two values of one column."
if u == v == "?": return 1
if isa(col, Sym): return u != v
u, v = norm(col, u), norm(col, v)
if u == "?": u = 1 if v < .5 else 0
if v == "?": v = 1 if u < .5 else 0
return abs(u - v)
def labelled(row): return row
def disty(data, row, **kw):
"Row's distance to the best goals (0 = ideal)."
row = labelled(row)
return minkowski(
(abs(norm(data.cols[at], row[at]) - data.goal[at])
for at in data.y if row[at] != "?"), **kw)
def distx(data, r1, r2, **kw):
"Distance between two rows over the x-columns."
return minkowski((gap(data.cols[at], r1[at], r2[at])
for at in data.x), **kw)
def wins(data):
"Grader: row -> % of gap to best closed, [-100,100]."
ys = sorted(disty(data,r) for r in data.rows)
lo, b4 = ys[0], ys[len(ys)//2]
return lambda r: max(-100, min(100,
100 * (1 - (disty(data,r)-lo) / (b4-lo+TINY))))
#-- Landscape ---------------------------------------------------
def project(rows, x, y):
"Row -> position on the east-west line (x=dist,y=goal)."
far = lambda r: max(rows, key=lambda z: x(z, r))
east = far(rows[0]); west = far(east)
if y(east) < y(west): east, west = west, east
c = x(east, west) + TINY
return lambda r: (x(east,r)**2 + c*c - x(west,r)**2)/(2*c)
def landscape(data):
"Label <=budget-check rows, best first. --landscape picks how."
y = lambda r: disty(data, r)
cap = the.budget - the.check
if the.landscape == "random":
return sorted(some(data.rows, cap), key=y)
x = lambda r1, r2: distx(data, r1, r2)
pool = shuffle(data.rows)
lab = {}
while len(lab) < cap and len(pool) >= 2*the.leaf:
here, k = [], 0
for r in pool:
if id(r) in lab: here.append(r)
elif k < the.grow and len(lab) < cap:
lab[id(r)] = r; here.append(r); k += 1
n = max(1, int((1-the.keepf)*len(pool)))
pool = sorted(pool, key=project(here, x, y))[n:]
return sorted(lab.values(), key=y)
#-- Tree build --------------------------------------------------
def mid(col): return max(col,key=col.get) if isa(col,Sym) else mu_(col)
def var(col): return entropy(col) if isa(col,Sym) else sd(col)
def size(col): return sum(col.values()) if isa(col,Sym) else n_(col)
def score(here, there):
"Split cost (lower=better): size-weighted nean of var (sd|entropy)."
a, b = size(here), size(there)
return (var(here)*a + var(there)*b) / (a + b + 1e-32)
def cuts(data,rows,at,Y,accum=Num):
"Yield (cost,at,v) splits with both sides >= the.leaf. accum=Num|Sym"
xy = [(r[at], Y(r)) for r in rows if r[at] != "?"]
n = len(xy)
tot = adds((y for _,y in xy), accum())
cut = lambda here,k: (score(here, mix(tot,here,-1)), at,k)
big = lambda lo: the.leaf <= lo <= n-the.leaf
if isa(data.cols[at], Sym):
for k in {x for x,_ in xy}:
ys = [y for x,y in xy if x==k]
if big(len(ys)): yield cut(adds(ys, accum()), k)
else:
xy.sort(); me=accum()
for j,(x,y) in enumerate(xy):
me = add(me, y)
if j+1 < n and x != xy[j+1][0] and big(j+1):
yield cut(me, x)
def has(row, col, at, v):
"Does row fall on the yes-side of a cut? (? = yes)."
w = row[at]
return w == "?" or (v == w if isa(col, Sym) else w <= v)
def tree(data, rows, Y=None, accum=Num, lvl=0):
"Recursively split rows on the min-cost cut. accum=Num|Sym."
Y = Y or (lambda r: disty(data, r))
t = o(at=None, mid=mid(adds((Y(r) for r in rows), accum())),
n=len(rows), rows=rows)
if len(rows) >= 2*the.leaf and lvl < the.maxd:
if cut := min((c for at in data.x
for c in cuts(data,rows,at,Y,accum)), default=0):
_, at, v = cut
col = data.cols[at]
yes, no = [], []
for r in rows: (yes if has(r,col,at,v) else no).append(r)
if yes and no:
t.at, t.v = at, v
t.yes = tree(data, yes, Y, accum, lvl+1)
t.no = tree(data, no, Y, accum, lvl+1)
return t
def leaf(data, t, row):
"Walk a row down to its leaf; return the leaf's mid."
while t.at is not None:
t = t.yes if has(row,data.cols[t.at],t.at,t.v) else t.no
return t.mid
#-- Tree show ---------------------------------------------------
def leaves(t):
"Yield every leaf node of a tree."
if t.at is None: yield t
else: yield from leaves(t.yes); yield from leaves(t.no)
def show(data, t):
"Pretty-print a tree: win, n, goal means, then branches."
y = lambda r: disty(data, r)
vs = sorted(y(r) for r in t.rows)
blo, bmd = vs[0], vs[len(vs)//2]
win= lambda rows: int(100*(1 - (
sum(y(r) for r in rows)/len(rows) - blo)/(bmd-blo+TINY)))
ws = [win(x.rows) for x in leaves(t)]
lo, hi = min(ws), max(ws)
rnd = lambda v: round(v, the.round) if isa(v, float) else v
cond = lambda t,b: "%s %s %s" % (data.names[t.at],
("==" if b else "!=") if isa(data.cols[t.at],Sym)
else ("<=" if b else ">"), rnd(t.v))
best, worst = chr(0x25B2), chr(0x25BC) # up/down triangles
head = " ".join("%8s" % data.names[a] for a in data.y)
print("%s %4s %5s %s" % (" ", "win", "n", head))
def go(t, pad="", edge=""):
w = win(t.rows)
m = " "
if t.at is None: m = best if w==hi else worst if w==lo else " "
mids = " ".join("%8.*f" % (the.round, mid(adds(r[a] for r in t.rows)))
for a in data.y)
print(("%s %4d %5d %s %s%s"
% (m, w, t.n, mids, pad, edge)).rstrip())
if t.at is not None:
p2 = pad + ("| " if edge else "")
kids = [(t.yes, cond(t,True)), (t.no, cond(t,False))]
for kid,e in sorted(kids, key=lambda ke: ke[0].mid):
go(kid, p2, e)
go(t)
#-- misc --------------------------------------------------------
def shuffle(lst): return random.sample(lst, len(lst))
def some(lst, k): return random.sample(lst, min(k, len(lst)))
def cliffs(xs, ys):
"Cliff's delta effect size in 0..1 (0 = identical)."
ys = sorted(ys); m = len(ys)
gt = sum(bisect_left(ys, x) for x in xs)
lt = sum(m - bisect_right(ys, x) for x in xs)
return abs(gt - lt) / (len(xs) * m + 1e-32)
def ks(xs, ys):
"Kolmogorov-Smirnov: max gap between the two CDFs."
xs, ys = sorted(xs), sorted(ys); n, m = len(xs), len(ys)
gap = lambda v: abs(bisect_right(xs,v)/n
- bisect_right(ys,v)/m)
return max(map(gap, xs + ys))
def cohen(xs, ys, eps=0.35):
"Small effect: |mean gap| < eps * pooled stdev."
x, y = adds(xs), adds(ys); n, m = n_(x), n_(y)
pooled = (((n-1)*sd(x)**2 + (m-1)*sd(y)**2)/(n+m-2))**.5
return abs(mu_(x) - mu_(y)) <= eps * (pooled + TINY)
def same(xs, ys, cliff=0.195, conf=1.36):
"True if xs,ys are statistically indistinguishable."
if not cohen(xs, ys): return False
if cliffs(xs, ys) > cliff: return False
n, m = len(xs), len(ys)
return ks(xs, ys) <= conf * ((n + m) / (n * m)) ** 0.5
def thing(s):
"Coerce a string to int/float/bool, else leave as str."
if (s[1:] if s[:1]=="-" else s).isdigit(): return int(s)
try: return float(s)
except ValueError: return s=="True" or (s!="False" and s)
def settings(doc):
"Parse '--key ... = val' lines of doc into an o()."
pat = r"--(\w+)\s+[^=\n]*=\s*(\S+)"
return o(**{k: thing(v) for k,v in re.findall(pat, doc)})
def csv(file, clean=lambda s: s.partition("#")[0].split(",")):
"Yield typed rows (lists) from a CSV file."
with open(file, encoding="utf-8") as f:
for line in f:
row = [x.strip() for x in clean(line)]
if any(row): yield [thing(x) for x in row]
#-- Tests (test_*) ----------------------------------------------
def test_disty():
"Rows sorted by disty: header, top 5, blank, bottom 5."
data = Data(csv(the.file))
rows = sorted(data.rows, key=lambda r: disty(data, r))
hdr = list(data.names) + ["disty"]
fmt = lambda r: [str(v) for v in r]+["%.3f" % disty(data,r)]
body = [fmt(r) for r in rows[:5] + rows[-5:]]
w = [max(len(row[c]) for row in [hdr]+body)
for c in range(len(hdr))]
line = lambda cs: print(" ".join(c.rjust(w[i])
for i,c in enumerate(cs)))
line(hdr)
for r in body[:5]: line(r)
print()
for r in body[5:]: line(r)
def test_landscape():
"20 shuffles; per run, best found by active landscape vs random pick."
data = Data(csv(the.file))
data.rows = some(data.rows, the.cap)
W, rows_out = wins(data), []
for i in range(20):
random.seed(the.seed + i); data.rows = shuffle(data.rows)
the.landscape = "active"; a = landscape(data)[0]
the.landscape = "random"; r = landscape(data)[0]
rows_out += [(disty(data,a), W(a), disty(data,r), W(r))]
the.landscape = "active"
up = chr(0x25B2) # marks whichever side won (lower disty) this run
print("rank aDisty aWin rDisty rWin win (%s)" % the.file.split("/")[-1])
for k,(ad,aw,rd,rw) in enumerate(sorted(rows_out)):
win = "tie" if ad==rd else ("%s active" % up if ad<rd else "%s random" % up)
print("%4d %7.3f %5.1f %7.3f %5.1f %s" % (k, ad, aw, rd, rw, win))
assert sum(ad for ad,_,_,_ in rows_out)/len(rows_out) < 0.3
def test_landscapes():
"One summary line: mean win/disty over 20 runs."
data = Data(csv(the.file))
data.rows = some(data.rows, the.cap)
W, ds, ws, n = wins(data), [], [], 0
for i in range(20):
random.seed(the.seed + i)
data.rows = shuffle(data.rows)
got = landscape(data)
ds += [disty(data,got[0])]; ws += [W(got[0])]; n = len(got)
print("%6.1f %7.3f %4d %s" % (sum(ws)/len(ws),
sum(ds)/len(ds), n, the.file.split("/")[-1]))
def test_tree():
"Build a tree over landscape's rows and print it."
random.seed(the.seed)
data = Data(csv(the.file))
data.rows = some(data.rows, the.cap)
show(data, tree(data, landscape(data)))
def test_trees():
"Same budget: random-trained vs landscape-trained tree."
random.seed(the.seed)
data = Data(csv(the.file))
data.rows = some(data.rows, the.cap)
land = landscape(data)
rand = some(data.rows, len(land))
W = wins(data)
for tag, rows in [("random", rand), ("landscape", land)]:
best = min(rows, key=lambda r: disty(data,r))
print("\n== %s n=%d best disty=%.3f win=%.1f ==" %
(tag, len(rows), disty(data,best), W(best)))
show(data, tree(data, rows))
def holdout(data):
"Budget rig: landscape train -> tree -> pick from test."
rows = shuffle(data.rows)
mid = len(rows)//2
train, test = rows[:mid], rows[mid:]
got = landscape(clone(data, train))
t = tree(data, got)
top = sorted(test, key=lambda r: leaf(data,t,r))[:the.check]
return min(top, key=lambda r: disty(data,r))
def vs(data, pick):
"active vs random over 20 runs of pick(); stat verdict line."
W, out = wins(data), {}
for mode in ("active", "random"):
the.landscape = mode; out[mode] = []
for i in range(20):
random.seed(the.seed + i); out[mode] += [W(pick(data))]
the.landscape = "active"
L, R = out["active"], out["random"]
ml, mr = sum(L)/20, sum(R)/20
v = "tie" if same(L, R) else ("land" if ml > mr else "rand")
print("%6.1f %6.1f %-5s %s" % (ml, mr, v,
the.file.split("/")[-1]))
def test_holdout():
"One run: the holdout-picked best row's disty and win."
random.seed(the.seed)
data = Data(csv(the.file))
data.rows = some(data.rows, the.cap)
b = holdout(data)
print("best disty %.3f win %.1f (%s)" % (disty(data,b),
wins(data)(b), the.file.split("/")[-1]))
def test_holdouts():
"active vs random landscape, through the holdout pipeline."
data = Data(csv(the.file))
data.rows = some(data.rows, the.cap)
vs(data, holdout)
def test_pure():
"active vs random landscape; best labelled row, no tree."
data = Data(csv(the.file))
data.rows = some(data.rows, the.cap)
vs(data, lambda d: landscape(d)[0])
def test_same():
"Validate same(): small shift = same, big shift = differ."
random.seed(the.seed)
a = [random.gauss(0, 1) for _ in range(20)]
shift = lambda d: [x + d for x in a]
print("shift same cliffs cohen")
for d in (0, 0.1, 0.3, 0.5, 1.0, 2.0):
b = shift(d)
print(" %+.1f %-5s %.2f %s" % (d, same(a,b),
cliffs(a,b), cohen(a,b)))
assert same(a, a) and not same(a, shift(2))
def test_all():
"Run every other test_*, reseting the seed before each."
for n,f in list(globals().items()):
if n.startswith("test_") and n != "test_all":
print("\n#", n, "-"*40)
try: random.seed(the.seed); f()
except Exception as e: print("FAIL:", n, type(e).__name__, e)
#-- Main --------------------------------------------------------
def main(funs):
"Apply --key=val to `the`, then run each named test_*."
if "-h" in sys.argv: return print(__doc__)
for a in sys.argv[1:]:
if a[:2]=="--" and "=" in a:
k,v = a[2:].split("=",1)
if k in vars(the): setattr(the, k, thing(v))
for a in sys.argv[1:]:
if (n := "test_"+a) in funs:
random.seed(the.seed); funs[n]()
the = settings(__doc__)
if __name__ == "__main__": main(globals())

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, ezr2
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 := ezr
MAIN := cli.py
EXT := py
LANG := python
Font := 4.7 # a touch smaller than konfig's 5, so ezr2.py still fits 6 cols
SRC := *.py
LINT := ruff check ezr.py cli.py
TOOLS := python3:run ruff:lint
PKG := python3 gawk ruff neovim tmux
$(KONFIG)/Makefile:
@test -f $@ || { echo "missing konfig: git clone http://tiny.cc/konfig $(KONFIG)"; exit 1; }
include $(KONFIG)/Makefile
# ---- test lanes + benchmark (repo-specific; after the include) ----
DATA ?= ../optimiz
JOBS ?= 24
test: ## quick tests (skips slow textmine)
@python3 -B cli.py --fast
slow: ## slow tests only (textmine)
@python3 -B cli.py --slow
testall: ## every test (fast + slow)
@python3 -B cli.py --all
win: ## hold-out win across every $(DATA)/*.csv (parallel): sorted list + mean
@ls $(DATA)/*.csv | sort -R | \
xargs -P $(JOBS) -I{} sh -c 'python3 -B cli.py --acquire20 "{}" 2>/dev/null' \
| gawk '{print $$1}' | sort -n | tee /tmp/ezr_win.txt | fmt
@gawk '{n++;s+=$$1} END{if(n) printf "\nmean=%.1f n=%d\n", s/n, n}' /tmp/ezr_win.txt
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 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 ezr2.py pure --file={} 2>/dev/null \
| tee $@
@python3 -B pctl.py < $@
#!/usr/bin/env python3 -B
"Read 'land rand verdict file' lines; tally wins + percentiles."
import sys
xs = [l.split() for l in sys.stdin if len(l.split()) >= 4]
n = len(xs) or 1
col = lambda i: sorted(float(r[i]) for r in xs
if abs(float(r[i])) < 1e4)
p = lambda v: [v[min(len(v)-1, int(q/100*len(v)))]
for q in (10,30,50,70,90)]
f = lambda nm,v: print("%-10s %5.1f %5.1f %5.1f %5.1f %5.1f"
% (nm, *p(v)))
print("\nn=%d datasets STAT WINS (Cliff's delta + KS)" % n)
for v in ("land", "rand", "tie"):
c = sum(r[2] == v for r in xs)
print(" %-5s %3d %4.1f%%" % (v, c, 100*c/n))
print("\n%-10s %5s %5s %5s %5s %5s" % ("WIN pct","10","30","50","70","90"))
f("landscape", col(0)); f("random", col(1))
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "ezr"
version = "0.9.5"
readme = ",ezr.md"
description = "Explainable multi-objective optimization"
authors = [{name = "Tim Menzies", email = "timm@ieee.org"}]
license = {text = "MIT"}
requires-python = ">=3.12"
dependencies = []
[project.scripts]
ezr = "cli:main"
[tool.setuptools]
py-modules = ["ezr", "cli"]
[project.urls]
Homepage = "http://tiny.cc/ezr"

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.

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