Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save timm/cf1069a18549c562d808a72c5d256dcc to your computer and use it in GitHub Desktop.
nuff - tiny one-file stdlib python tricks (records, io, stats, columns, distance); no global config. http://tiny.cc/nuff

AuthorLanguageDepsLicensePurpose

nuff: one tiny file of reusable Python tricks — attribute-dicts, typed CSV, pretty-print, seeded randomness, non-parametric stats, minimal column summaries, and row distances. Pure stdlib, zero dependencies. The cut-down kernel under my bigger apps, with no global config: every parameter (p, cliff, conf, rng) is passed as a keyword, so any function lifts out into another project.

from nuff import o, csv, say, Data, disty, same, shuffle
import random

d = Data(csv("../optimiz/auto93.csv"))         # build a table
say(disty(d, d.rows[0], p=1))                  # row->goal distance, p a kwarg
shuffle(rows, rng=random.Random(1))            # repeatable, own RNG
same(a, b, cliff=0.195, conf=1.36)             # are two samples the same?

Sections: NAME | DESIGN | API | STYLE | LICENSE | AUTHOR

Files: nuff.py | test_nuff.py | Makefile | pyproject.toml

NAME

nuff - one file of tiny stdlib python tricks (no global config)

DESIGN

One file, themed sections, no module-level `the`. Tuning rides
along as keyword args, so any function drops into another app:
  disty(data, row, p=2)
  same(xs, ys, cliff=0.195, conf=1.36)
  shuffle(lst, rng=random.Random(seed))

API

records / io / format
  o              SimpleNamespace alias: o(x=1).x == 1
  thing(s)       coerce str -> int|float|bool|str
  settings(s)    every var=val in s -> an o (vals coerced)
  csv(file)      yield typed rows ('#' = comment)
  say(x, dec=2)  pretty str; whole floats as ints
  sho(rows,just) align list[list[str]] to cols; just='>'/'<'
  main(funs)     run funs[name] for each --name in argv

rand   (pass your own random.Random(seed) for repeatability)
  shuffle(lst, rng)     shuffled copy
  some(lst, k, rng)     sample without replacement
  one(lst, rng)         one random item

stats  (non-parametric "are these two the same?")
  cliffs(xs, ys)        Cliff's delta effect size 0..1
  ks(xs, ys)            Kolmogorov-Smirnov CDF gap
  same(xs, ys, cliff=.195, conf=1.36)
  top_tier(groups, ...) names tied for best (min median)

columns  (lightweight: Sym = dict {value:count}, Num = (n,mu,m2);
          dispatch by isa(col, Sym), no tags)
  Sym() Num(n,mu,m2)       a dict / a 3-tuple; n_/mu_/m2_ read it
  welford(num,v,inc=1)     Num + v (inc=-1 removes) -> new Num
  mix(i,j,inc=1)           merge two same-type cols (inc=-1 = out)
  mid(col) spread(col)     mode/mean, entropy/stdev
  norm(num,v)              0..1 via a logistic on v's z-score (Num)

table  (immutable cols -> add RETURNS the new it, never mutates)
  add(it,v,inc=1)          fold v into a Sym/Num, or a row into a
                           Data; returns it (inc=-1 subtracts)
  adds(src,it=None)        fold all of src into it (default a Num)
  Data(rows)               -> o(names, cols{at:col}, x, y, goal,
                           klass, rows); first row = the header.
                           Upper=Num lower=Sym; +/-/! = goal y;
                           + maximizes; ! = klass; X = skip
  clone(data, src=None)    new Data, same columns, fresh rows

distance  (exponent `p` is a keyword)
  minkowski(vals, p=2)
  disty(data, row, p=2)      distance to best goals (0=ideal)
  distx(data, r1, r2, p=2)   distance between two rows on x
  gap(col, u, v)             per-column value distance 0..1

bayes  (naive bayes; m, k carried as kwargs, no global the)
  like(col, v, n=0, prior=0, k=1)       how a column likes a value
                                        (n = #rows, for the Sym case)
  likes(data, row, nrows, nklasses)     log-likelihood of a row
  confuse(pairs)                        (want,got) -> per-class
                                        o(pd, pf, prec, acc)

tree  (min-variance binary tree; exact cuts, no binning)
  tree(data, leaf=3, maxDepth=12)       build; y defaults to disty
  treeCuts(data, rows, y)               yield candidate cuts
  treeCut(data, rows, y, leaf=3)        the best cut (or None)
  treePredict(t, row)                   leaf disty mean for a row
  treeShow(data, t)                     table: +/- d2h n ymeans tree

STYLE

Minimal python: one file, one-line comments, ~65-char lines,
very short functions, `i` (not self), records over classes.
Threshold for a new file vs a new gist: parts you *import* stay
in here; *wholes* you *run* (apps, other languages, data) get
their own gist.

LICENSE

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

AUTHOR

Tim Menzies <timm@ieee.org>
__pycache__/
*.pyc
dist/
build/
*.egg-info/
__ __
_ __ _ _ / _|/ _|
| '_ \| | | | |_| |_
| | | | |_| | _| _|
|_| |_|\__,_|_| |_|
tiny python tricks. http://tiny.cc/nuff
"the kernel under my apps, with no global the. nuff said."

Changelog

Notable, interface-affecting changes. Newest first.

0.2.0 — unreleased

Lightweight column model (modeled on fft.py). Breaking.

  • Columns are bare values, not records. Sym is now a {value: count} dict (Sym = dict); Num is a (n, mu, m2) tuple. Gone: the old o-records with .txt/.at/.n/.has/.mu. Read a Num's mean with mu_(num); dispatch with isa(col, Sym).
  • Cols removed. Roles fold into Data, which is now o(names, cols{at: col}, x, y, goal, klass, rows). Columns are keyed by their at-index, not held in a list of records. data.cols.ydata.y; data.cols.klass.atdata.klass.
  • add(it, v, inc=1) RETURNS the new it (Num is an immutable tuple, so it cannot mutate in place). Use it = add(it, v); adds does this for you. inc=-1 subtracts; Num resets if n<2.
  • merge renamed to mix(i, j, inc=1) (inc=-1 mixes j back out).
  • o is now types.SimpleNamespace (was a dict subclass). Attribute access only — d.x, not d["x"].
  • Removed the symp/nump predicates (use isa(col, Sym)), the unused n_/m2_ accessors (kept mu_), and the standalone Cols.
  • Fixed gap for missing values: normalize the known values first, then push a missing value to the far end (max distance), per the ezr/Aha rule. Old code collapsed the gap toward 0.
  • csv yields tuples, not lists, so rows are hashable and can key a cache directly (no tuple(r) at call sites). Rows are never mutated in place, so this is safe. Don't pass a nuff row to code that assigns row[i] = ... (e.g. ezr's oracleNearest).
  • data.goal[at] is now a bool (s[-1] == "+").
  • Export BIG = 1e32 (the "no cut yet" sentinel).
  • Add sho(rows, just) — align a list[list[str]] into columns; just is a '>'/'<' per-column right/left justify string.
  • FFT support, minimal: all fft knowledge lives in ffts(t) (fans a tree into FFT decision-lists). fft(t) = min(ffts(t), key=leaf-mu) picks the best FFT. treePredict walks a tree OR an FFT (tree nodes carry yes=True, FFT nodes carry the exit side). tree is always per-node ("many"); depth via maxDepth. (No ifan/pick/-D.)
  • Add a min-variance binary tree over the x-columns: tree(data) builds it (leaves keep the disty mean + per-goal means); treeCuts/treeCut find splits (group-by-value, exact, no binning); treePredict(t,row); treeShow(data,t) prints a table (+/- best/worst leaf, d2h, n, goal means, then the tree).
  • like(col, v, n=0, prior=0, k=1) gains an n (row count) arg; the Sym denominator uses it instead of sum(col.values()) (Sym no longer stores its own count). likes passes it for you.

0.1.0

  • Initial release as nuff (renamed from gape). One-file, stdlib-only: records, io, rand, non-parametric stats, columns, distance, naive bayes. No global config — tuning via kwargs.
# vim: ts=2 sw=2 sts=2 et :
# knobs only; shared targets live in $(KONFIG)/Makefile
KONFIG ?= ../konfig
APP := nuff
MAIN := nuff.py
EXT := py
LANG := python
SRC := *.py
LINT := ruff check nuff.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: ## smoke-test the three modules (needs ../optimiz)
@python3 -B test_nuff.py && echo "ok nuff"
push2pypi: ## build + upload to PyPI (needs ~/.pypirc account token)
@python3 -m build && python3 -m twine upload dist/*
@rm -rf dist build *.egg-info
#!/usr/bin/env python3 -B
# nuff.py: tiny python tricks in one file -- records, io, format,
# rand, stats, columns, distance. (c) 2026 Tim Menzies, MIT.
# No global config: params (p, cliff, conf, rng) as kwargs.
import re, sys, random, traceback
from math import log2, log, exp, sqrt, pi
from bisect import bisect_left, bisect_right
from types import SimpleNamespace as o # o(a=1).a == 1
isa = isinstance
BIG = 1e32 # "no cut yet" sentinel
# ---- records, io, format --------------------------------------
def thing(s):
"Coerce a (trimmed) string to int, float, bool, else 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(s):
"Parse every var=val in a string into an o (vals coerced)."
pairs = re.findall(r"(\w+)=(\S+)", s)
return o(**{k: thing(v) for k, v in pairs})
def csv(file, clean=lambda s: s.partition("#")[0].split(",")):
"Yield typed rows from a CSV file ('#' starts a comment)."
with open(file, encoding="utf-8") as f:
for line in f:
row = [x.strip() for x in clean(line)] # strip once, here
if any(row): # skip blank/comment lines
yield tuple(thing(x) for x in row) # hashable: rows key caches
def say(x, dec=2):
"Pretty string: whole floats as ints, else `dec` places."
if isa(x, float):
return str(int(x)) if x == int(x) else f"{x:.{dec}f}"
if isa(x, o): x = vars(x) # unwrap a namespace
if isa(x, dict): return "{" + ", ".join(f"{k}: {say(v,dec)}"
for k, v in sorted(x.items())) + "}"
if isa(x, (list, tuple)):
return "[" + ", ".join(say(v, dec) for v in x) + "]"
return str(x)
def sho(rows, just):
"Align rows (list[list[str]]) to columns; just='>'/'<' per col."
w = [max(len(r[c]) for r in rows) for c in range(len(just))]
j = lambda c, s: s.rjust(w[c]) if just[c] == ">" else s.ljust(w[c])
return "\n".join(" ".join(j(c, s) for c, s in enumerate(r)).rstrip()
for r in rows)
def run(fns, name, seed):
"Reseed, run one test_* fn; return 1 on failure else 0."
random.seed(seed)
try: fns[name](); return 0
except Exception: traceback.print_exc(); return 1
def usage(g, fns, help=None):
"Print `help` (default g's __doc__) then the test_* commands."
print((help if help is not None else g.get("__doc__") or "").strip())
print("\ncommands:", *(" --" + n for n in fns))
def main(g, help=None, argv=None, seed=1):
"""Run g's test_* fns: --name picks some, none=all; reseed.
-h prints usage. --seed=N sets the seed. Pass globals() as g."""
fns = {k[5:]: v for k, v in g.items() if k.startswith("test_")}
argv = sys.argv[1:] if argv is None else argv
if "-h" in argv or "--help" in argv:
return usage(g, fns, help) or 0
for a in argv:
if a.startswith("--seed="): seed = int(a.split("=", 1)[1])
named = [a[2:] for a in argv if a[:2] == "--" and "=" not in a]
todo = [n for n in named if n in fns] or list(fns)
return sum(run(fns, n, seed) for n in todo)
# ---- rand: own random.Random(seed) for repeatability ----------
def shuffle(lst, rng=random):
"Shuffled copy via the given RNG (no global config)."
lst = lst[:]; rng.shuffle(lst); return lst
def some(lst, k=512, rng=random):
"Up to k items sampled without replacement, via rng."
return rng.sample(lst, min(k, len(lst)))
def one(lst, rng=random):
"One random item via rng."
return rng.choice(lst)
# ---- stats: are two samples the same? -------------------------
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) # ys below x
lt = sum(m - bisect_right(ys, x) for x in xs) # ys above x
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 same(xs, ys, cliff=0.195, conf=1.36):
"True if xs,ys are statistically indistinguishable."
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 top_tier(groups, cliff=0.195, conf=1.36):
"Groups {name:nums} tied for best (lowest median wins)."
rank = lambda lst: sorted(lst)[len(lst) // 2]
items = sorted(groups.items(), key=lambda kv: rank(kv[1]))
best, (k0, lst0) = {}, items[0]
for k, lst in items:
if k == k0 or same(lst0, lst, cliff, conf): best[k] = lst
else: break
return best
# ---- columns: a Sym is a {value:count} dict, Num a 3-tuple ----
Sym = dict # isa(col, Sym) reads "col is a Sym"
def Num(n=0, mu=0, m2=0): return (n, mu, m2) # count,mean,sumsq
def n_(x): return x[0] # a Num's count (n, mu, m2)
def mu_(x): return x[1] # a Num's mean
def m2_(x): return x[2] # a Num's sum of squares
def sd(num):
"Standard deviation of a Num from its m2."
n, mu, m2 = num
return 0 if n < 2 else (max(0, m2) / (n - 1)) ** 0.5
def welford(num, v, inc=1):
"Num + v (inc=-1 removes); returns a new (n, mu, m2)."
n, mu, m2 = num
if (n := n + inc) <= 0: return Num()
d = v - mu; mu += inc * d / n
return n, mu, m2 + inc * d * (v - mu)
def norm(num, v):
"Map v to 0..1 via a logistic on its z-score (Num only)."
if v == "?": return v
z = (v - mu_(num)) / (sd(num) + 1e-32)
return 1 / (1 + exp(-1.7 * max(-3, min(3, z))))
def mix(i, j, inc=1):
"Combine two same-type cols; inc=-1 removes 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, m2)
# ---- table: roles in Data, columns held by at-index -----------
def Data(src=None):
"""Table o(names, cols{at:col}, x, y, goal, klass, rows).
Upper=Num lower=Sym; +-! = y goal (+max); ! klass; X skip."""
src = iter(src or [])
data = o(names=next(src, []), cols={}, x=[], y=[], goal={},
klass=None, rows=[])
return adds(src, roles(data))
def roles(data):
"Assign column roles from data.names; returns 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.append(at)
data.goal[at] = s[-1] == "+"
if s[-1] == "!": data.klass = at
else: data.x.append(at)
return data
def add(it, v, inc=1):
"Add to a Sym/Num/Data; RETURNS the (new) it. Skips '?'."
if isa(it, Sym):
if v != "?": it[v] = it.get(v, 0) + inc
return it
if isa(it, tuple):
return welford(it, v, inc) if v != "?" else it
(it.rows.append if inc == 1 else it.rows.remove)(v)
for at in it.cols: it.cols[at] = add(it.cols[at], v[at], inc)
return it
def adds(src, it=None):
"Fold src into it (a fresh Num by default); returns it."
if it is None: it = Num()
for v in src: it = add(it, v)
return it
def clone(data, src=None):
"New Data with data's columns; optionally seed src rows."
return Data([data.names] + (src or []))
def mid(col):
"Central tendency: mode (Sym) or mean (Num)."
return max(col, key=col.get) if isa(col, Sym) else mu_(col)
def mids(data):
"Central tendency of every column, keyed by at-index."
return {at: mid(col) for at, col in data.cols.items()}
def spread(col):
"Diversity: entropy (Sym) or stdev (Num)."
if not isa(col, Sym): return sd(col) # Num
n = sum(col.values())
return -sum(c/n * log2(c/n) for c in col.values() if c) # 0*log0 = 0
# ---- distance: exponent `p` is a keyword, never global --------
def minkowski(vals, p=2):
"Aggregate per-item distances via the p-norm."
total, n = 0, 0
for v in vals: total += v ** p; n += 1
return (total / (n or 1)) ** (1 / p)
def disty(data, row, **kw):
"Distance of a row to the best goals (0 = ideal)."
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 gap(col, u, v):
"Distance between two values of one column (0..1)."
if u == v == "?": return 1
if isa(col, Sym): return u != v # Sym
u, v = norm(col, u), norm(col, v) # "?" passes through
if u == "?": u = 1 if v < 0.5 else 0 # a missing value
if v == "?": v = 1 if u < 0.5 else 0 # goes to the far end
return abs(u - v)
# ---- bayes: naive-bayes likelihood (m, k as kwargs) -----------
def like(col, v, n=0, prior=0, k=1):
"How a column likes v (Sym: m-estimate, Num: gauss); n=#rows."
if isa(col, Sym):
return (col.get(v, 0) + k * prior) / (n + k)
s = sd(col) + 1e-32; z = 2 * s * s
return exp(-(v - mu_(col)) ** 2 / z) / sqrt(pi * z)
def likes(data, row, nrows, nklasses, m=2, k=1):
"Log-likelihood of a row under this data (naive bayes)."
n = len(data.rows)
prior = (n + m) / (nrows + m * nklasses)
ls = [like(data.cols[at], v, n, prior, k) for at in data.x
if (v := row[at]) != "?"]
return log(prior) + sum(log(x) for x in ls if x > 0)
def confuse(pairs):
"(want, got) pairs -> o(pd, pf, prec, acc) per want."
out, n = {}, len(pairs)
for k in sorted({want for want, _ in pairs}):
tp = sum(want == k and got == k for want, got in pairs)
fn = sum(want == k and got != k for want, got in pairs)
fp = sum(want != k and got == k for want, got in pairs)
tn = n - tp - fn - fp
out[k] = o(label=k, n=tp + fn, pd=tp / (tp + fn + 1e-32),
pf=fp / (fp + tn + 1e-32),
prec=tp / (tp + fp + 1e-32),
acc=(tp + tn) / (n + 1e-32))
return out
# ---- tree: build a min-variance binary tree over the x-columns -
def has(v, lo, hi): return v == "?" or lo <= v <= hi
def _impurity(col):
"Split cost: a Num's sum-of-squares, or a Sym's entropy*count."
return m2_(col) if isa(col, tuple) else spread(col) * sum(col.values())
def _separate(data, rows, y, Y=Num):
"Yield each (score, at, lo, hi, yes, no, n) split candidate; Y=y-col kind."
ys = {r: y(r) for r in rows} # cache y per (hashable) row
for at in data.x:
sym = isa(data.cols[at], Sym)
rs = sorted((r for r in rows if r[at] != "?"), key=lambda r: r[at])
tot = Y()
for r in rs: tot = add(tot, ys[r])
yes, run, run_n = Y(), Y(), 0 # distinct objs: Sym add mutates in place
for k, r in enumerate(rs):
run = add(run, ys[r]); run_n += 1
if k+1 < len(rs) and rs[k+1][at] == r[at]: continue # only cut at boundaries
if sym: grp, n = run, run_n
else: grp, n = (yes := mix(yes, run)), k+1
run = Y(); run_n = 0
no = mix(tot, grp, -1)
lo = r[at] if sym else -BIG
yield _impurity(grp) + _impurity(no), at, lo, r[at], grp, no, n
def treeCut(data, rows, y, leaf=3, Y=Num):
"The (at, lo, hi) of the lowest-impurity cut, or None."
ok = (c for c in _separate(data, rows, y, Y) if c[6] >= leaf)
best = min(ok, key=lambda c: c[0], default=None)
return best and best[1:4]
def tree(data, rows=None, y=None, leaf=3, lvl=0, maxDepth=12, Y=Num):
"""Min-impurity binary tree; yes=match. Y=Num+y=disty -> regression
(leaf mu=mean); Y=Sym+y=class label -> classification (leaf mu=mode)."""
rows = data.rows if rows is None else rows
y = y or (lambda r: disty(data, r))
yc = adds((y(r) for r in rows), Y())
what = lambda a: Num() if isa(data.cols[a], tuple) else Sym()
ymid = [mid( adds( (r[a] for r in rows), what(a))) for a in data.y]
t = o(at=None, mu=mid(yc), n=len(rows), ymid=ymid)
if len(rows) >= 2*leaf and lvl < maxDepth and _impurity(yc) > 0 and \
(cut := treeCut(data, rows, y, leaf, Y)): # stop on a pure node
at, lo, hi = cut
yes, no = [], []
for r in rows: # one pass
(yes if has(r[at], lo, hi) else no).append(r)
if len(yes) >= leaf and len(no) >= leaf:
t.at, t.lo, t.hi, t.yes = at, lo, hi, True # go left on match
t.left = tree(data, yes, y, leaf, lvl+1, maxDepth, Y)
t.right = tree(data, no, y, leaf, lvl+1, maxDepth, Y)
return t
def treePredict(t, row):
"Walk a tree OR an FFT to a leaf; return its value (disty mean / class mode)."
while t.at is not None: # yes-side = left
t = t.left if has(row[t.at], t.lo, t.hi) == t.yes else t.right
return t.mu
# ---- FFTs: `ffts` is the only fft-aware code; `fft` picks one -
def ffts(t):
"Fan a tree into FFTs: each level, one child exits as a leaf."
if t.at is None:
yield "", t
else:
for yes in (False, True): # which child exits
ex, cont = (t.left, t.right) if yes else (t.right, t.left)
lf = o(at=None, mu=ex.mu, n=ex.n) # exit collapsed to leaf
for bias, rest in ffts(cont):
yield str(int(yes)) + bias, o(at=t.at, lo=t.lo, hi=t.hi,
yes=yes, left=lf, right=rest)
def fftLeaves(t):
"An FFT's leaves: the exit leaf per cue, plus the final leaf."
while t.at is not None:
yield t.left; t = t.right
yield t
def fft(t, guard=3):
"From t's fan, the FFT with the lowest leaf-mu (leaves n>=guard)."
def lo(f):
ms = [lf.mu for lf in fftLeaves(f) if lf.n >= guard]
return min(ms) if ms else BIG
return min((f for _, f in ffts(t)), key=lo)
# ---- tree: pretty-print as an indented table ------------------
def _treeShow1(data, t, best, worst, out, lvl=0, edge=""):
"Rows of [mark,d2h,n,goal-means,text]; edge=test that reached node."
def cue(t, yes): # edge label into a child
nm = data.names[t.at] # 'Kloc <= 182' / 'Kloc > 182'
if t.lo == t.hi:
return f"{nm} == {say(t.lo)}" if yes else f"{nm} != {say(t.lo)}"
return f"{nm} <= {say(t.hi)}" if yes else f"{nm} > {say(t.hi)}"
num = isa(t.mu, (int, float)) # +/- only ranks regression leaves
mark = "+" if t.at is None and num and t.mu == best else \
"-" if t.at is None and num and t.mu == worst else ""
out += [[mark, say(t.mu), say(t.n)]
+ [say(v) for v in t.ymid] + ["| "*max(0, lvl-1) + edge]]
if t.at is not None:
kids = [(t.left, cue(t, True)), (t.right, cue(t, False))]
kids.sort(key=lambda kt: kt[0].mu) # better first
for kid, e in kids:
_treeShow1(data, kid, best, worst, out, lvl+1, e)
def treeShow(data, t):
"+/- best/worst leaf, d2h, n, goal means, then the tree."
mus = []
def leaves(t):
if t.at is None: mus.append(t.mu)
else: leaves(t.left); leaves(t.right)
leaves(t)
ynm = [data.names[a] for a in data.y]
head = ["", "d2h", "n"] + ynm + ["tree"]
out = [head]
_treeShow1(data, t, min(mus), max(mus), out)
print(sho(out, ">"*(len(head)-1) + "<")) # nums>, tree<
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "nuff"
version = "0.2.0"
readme = ",nuff.md"
description = "Tiny stdlib-only python tricks: records, io, stats, columns, distance"
authors = [{name = "Tim Menzies", email = "timm@ieee.org"}]
license = {text = "MIT"}
requires-python = ">=3.12"
dependencies = [] # stdlib only, by design
[tool.setuptools]
py-modules = ["nuff"]
[project.urls]
Homepage = "http://tiny.cc/nuff"
# linting: nuff's minimal style on purpose -- 2-space indent, short
# names (o, i, v, n), terse one-liners, # header not a docstring.
[tool.pylint.format]
indent-string = " " # 2 spaces -> silences W0311
max-line-length = 80
[tool.pylint.messages_control]
disable = [
"invalid-name", # short names: o, i, v, n, xs ...
"missing-module-docstring",# header is a # comment, by choice
"missing-function-docstring", # one-line "..." strings count? keep off
"too-few-public-methods", # o is a 1-trick dict subclass
"consider-using-f-string",
]
# ruff (the Makefile's `make check`) already ignores 2-space indent;
# only tighten what you want.
[tool.ruff.lint]
ignore = [
"E701", # one-line if/for/def colon bodies
"E702", # `a; b` multiple statements
"E731", # `f = lambda ...` assignments
"E741", # short names like l/o/i
"E401", # grouped `import a, b, c`
]
#!/usr/bin/env python3 -B
"""test_nuff.py: nuff smoke tests.
Usage: python3 -B test_nuff.py [--name ...] [--seed=N]
no --name runs every test; -h shows this help.
"""
import random
import os
from nuff import (o, Sym, csv, thing, settings, say, sho, main, shuffle, some,
same, top_tier, Data, clone, likes, confuse,
disty, distx, tree, treePredict, treeShow)
def _confuseShow(name, m):
"Print a confuse() result as an aligned per-class table (% scores, class RHS)."
pct = lambda x: say(round(100 * x)) # 0.82 -> 82
rows = [["n", "pd", "pf", "prec", "acc", ""]]
for lab, c in sorted(m.items()):
rows.append([say(c.n), pct(c.pd), pct(c.pf), pct(c.prec), pct(c.acc), str(lab)])
print(f"\n{name}:")
print(sho(rows, ">"*5 + "<")) # nums right, class label RHS
def test_o():
p = o(a=1, b=3.0); assert p.a == 1 and say(p.b) == "3"
def test_thing():
assert thing("3") == 3 and thing("2.5") == 2.5 and thing("x") == "x"
def test_settings():
s = settings("seed=1 p=2 who=tim")
assert s.seed == 1 and s.p == 2 and s.who == "tim"
def test_rand():
"Own RNG -> repeatable; no global state."
r1, r2 = random.Random(1), random.Random(1)
xs = list(range(20))
assert shuffle(xs, rng=r1) == shuffle(xs, rng=r2)
assert len(some(xs, 5, rng=random.Random(1))) == 5
def test_cols():
d = Data([["name", "Age", "Weight-", "sick!"]])
assert len(d.x) == 2 and len(d.y) == 2 and d.klass == 3
assert d.names[d.klass] == "sick!"
def test_data():
d = Data(csv("../optimiz/auto93.csv"))
assert len(d.rows) == 398 and len(d.y) == 3
assert distx(d, d.rows[0], d.rows[0]) == 0
assert 0 <= disty(d, d.rows[0]) <= 1
assert disty(d, d.rows[0], p=1) != disty(d, d.rows[0], p=3)
def test_like():
"naive bayes self-classify beats chance (needs ../klassif)."
f = "../klassif/diabetes.csv"
if not os.path.exists(f): return
d = Data(csv(f)); k = d.klass
g = {}
for r in d.rows: g.setdefault(r[k], []).append(r)
ds = {cl: clone(d, rows) for cl, rows in g.items()}
ok = sum(max(ds, key=lambda cl: likes(ds[cl], r, len(d.rows), len(ds)))
== r[k] for r in d.rows)
assert ok / len(d.rows) > 0.7
def test_confuse():
"per-class pd/pf from (want, got) pairs."
m = confuse([("a","a"), ("a","a"), ("b","b"), ("b","a")])
assert m["a"].pd == 1 and m["b"].pd == 0.5 and m["a"].pf == 0.5
def test_stats():
assert same([1,2,3,4], [1,2,3,4])
assert not same([1,2,3,4], [7,8,9,10])
assert list(top_tier({"a":[1,2,3], "b":[9,9,9]})) == ["a"]
def test_tree():
"Build + print a min-variance regression tree on auto93."
d = Data(csv("../optimiz/auto93.csv"))
t = tree(d, leaf=30) # bigger leaf -> compact tree
assert t.at is not None # the root split
assert 0 <= treePredict(t, d.rows[0]) <= 1
treeShow(d, t) # the print check
def test_tree_classify():
"Classification trees (Y=Sym); print confuse stats for each dataset."
for name, leaf, floor in [("diabetes", 30, 0.75), ("soybean", 20, 0.55)]:
f = f"../klassif/{name}.csv"
if not os.path.exists(f): continue
d = Data(csv(f)); k = d.klass
t = tree(d, y=lambda r: r[k], Y=Sym, leaf=leaf) # discrete y -> class tree
pairs = [(r[k], treePredict(t, r)) for r in d.rows]
assert treePredict(t, d.rows[0]) in {r[k] for r in d.rows} # leaf = label
treeShow(d, t) # show the tree
m = confuse(pairs) # nuff's scorer
_confuseShow(name, m)
acc = sum(w == g for w, g in pairs) / len(pairs)
assert acc > floor # self-classify beats chance
assert all(0 <= c.pd <= 1 and 0 <= c.pf <= 1 for c in m.values())
if __name__ == "__main__":
raise SystemExit(main(globals(), __doc__)) # __doc__ = help above

nuff TODO

tree: _impurity mixes m2 (Num) and entropy (Sym)

Looks like it compares two different units/scales. It does NOT.

_separate builds the yes/no groups from y(r) into a Y() accumulator. Y is fixed for the whole tree:

  • regression (y=disty, Y=Num) -> _impurity = m2 (total deviance)
  • classification (y=class, Y=Sym) -> _impurity = entropy * count

So inside one treeCut's min(), every candidate hits the SAME branch. m2 vs m2, or entropy vs entropy. The branch is chosen once by the tree's Y, never per-candidate. Units are consistent within every comparison.

Caveat (real, but the code never trips it): scores from a regression tree and a classification tree are not comparable to each other.

Possible cleanups to consider:

  • Rename _impurity -> make the "score in the tree's own y-units" contract obvious; or split into _devianceNum / _entropySym dispatched by Y so the single-type invariant is explicit.
  • Assert / document that Y matches the type of y(r) values.
  • Decide if impurity should be per-row (mean) vs summed. Summed deviance is the standard CART criterion and is fine here (the yes/no partition covers a fixed row set), so probably leave it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment