vertiq 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vertiq/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """vertiq: decision models for customers, revenue and inventory, for any business vertical.
2
+
3
+ Plain data in, plain data out. vertiq never imports a web framework, an ORM or any host product:
4
+ a host (a loyalty app, a POS, an enterprise CRM) reads its own database into the data
5
+ contract (vertiq.contract), calls these functions, and stores the models (plain dicts) wherever
6
+ it likes.
7
+
8
+ learn logistic and ridge models over named features, AUC, calibration, tests, FDR control
9
+ features what is known about a customer (account) at a moment, from their events only
10
+ churn the chance a customer returns within a window, shared across businesses and verticals
11
+ uplift who a message or an offer actually brings back, learned from control groups
12
+ value revenue per campaign vs control, bill size, referrers
13
+ affinity choices: combos, cross-sell, next items, refill cycles
14
+ insights patterns tested before they are shown
15
+ inventory reorder, slow movers, new arrivals, margins
16
+ offers spend-more offers sized per customer
17
+ trends trends matched to what a business sells and a customer buys
18
+ research web tactics, kept only with a source and a mechanism that can test them
19
+ dataset checks, masking and exports for training a language model
20
+ experiments comparing message variants against each other and a control group
21
+ verticals per-vertical settings (windows, nouns, currency, tactics)
22
+ """
23
+ __version__ = "0.2.0"
vertiq/actions.py ADDED
@@ -0,0 +1,104 @@
1
+ """Smart actions: the few things worth doing now, each ready to become a campaign draft (who, why,
2
+ with which offer, and what it should bring in). A host turns an action into its own campaign.
3
+
4
+ winback persuadable and needs-offer customers from targeting (goal winback)
5
+ second_visit first-timers targeting says a message would bring back
6
+ slow_mover a slow-moving item, offered only to the people who buy it or its category
7
+ new_arrival something new, told to the people who buy that kind of thing
8
+ cross_sell people who buy A but never B, when A and B go together
9
+ referral loyal regulars, asked to bring a friend
10
+
11
+ Every action carries a stable key (so "create this" finds the same action again), the audience ids,
12
+ the goal, and either an expected extra revenue (when targeting has one) or the money at stake.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from . import inventory
17
+
18
+ MIN_AUDIENCE = 5
19
+
20
+
21
+ def _targeted(plan: dict | None, key: str, title: str, goal: str, why: str, only=None) -> dict | None:
22
+ if not plan or plan.get("status") != "ok":
23
+ return None
24
+ people = [p for p in plan["customers"] if p["send"] and (only is None or only(p))]
25
+ if len(people) < MIN_AUDIENCE:
26
+ return None
27
+ offer = plan.get("offer")
28
+ with_offer = [p["id"] for p in people if p["with_offer"]]
29
+ return {"key": key, "title": title.format(n=len(people)), "goal": goal, "why": why,
30
+ "audience_ids": [p["id"] for p in people], "audience_count": len(people),
31
+ "offer": offer["label"] if offer and with_offer else "", "with_offer_count": len(with_offer),
32
+ "expected_extra_revenue": round(sum(p["expected_value"] for p in people)), "source": plan.get("source")}
33
+
34
+
35
+ def _fans(choices: dict, inv_view: dict | None, item: str) -> list[str]:
36
+ """Customers who bought `item`, or anything in its catalogue category."""
37
+ k = inventory.key(item)
38
+ cat = (inv_view or {}).get("category", {}).get(k, "")
39
+ out = []
40
+ for cid, bought in (choices.get("_bought") or {}).items():
41
+ keys = {inventory.key(i) for i in bought}
42
+ if k in keys or (cat and any((inv_view or {}).get("category", {}).get(x) == cat for x in keys)):
43
+ out.append(cid)
44
+ return out
45
+
46
+
47
+ def suggest(*, winback_plan: dict | None = None, second_visit_plan: dict | None = None, choices: dict | None = None,
48
+ inv_report: dict | None = None, inv_view: dict | None = None, referrers: dict | None = None,
49
+ one_visit_ids: set | None = None, currency: str = "₹", limit: int = 6) -> list[dict]:
50
+ out = []
51
+ a = _targeted(winback_plan, "winback", "Win back {n} customers who would come back if asked", "winback",
52
+ "Targeting learned from your control groups says a message brings these people back.")
53
+ if a:
54
+ out.append(a)
55
+ a = _targeted(second_visit_plan, "second_visit", "Bring {n} first-timers back for a second visit", "secondVisit",
56
+ "A second visit is where most customers are won or lost.",
57
+ only=(lambda p: p["id"] in one_visit_ids) if one_visit_ids is not None else None)
58
+ if a:
59
+ out.append(a)
60
+ ch = choices or {}
61
+ rep = inv_report or {}
62
+ for s in (rep.get("slow_movers") or [])[:2]:
63
+ who = _fans(ch, inv_view, s["name"])
64
+ if len(who) >= MIN_AUDIENCE:
65
+ out.append({"key": f"slow_mover:{inventory.key(s['name'])}", "goal": "custom",
66
+ "title": f"Offer {s['name']} to {len(who)} people who buy it or its kind",
67
+ "why": f"{currency}{s.get('tied_up', 0):,} is tied up in it and it's selling slowly.",
68
+ "audience_ids": who, "audience_count": len(who), "offer": "", "item": s["name"],
69
+ "money_at_stake": s.get("tied_up", 0)})
70
+ for n in (rep.get("new_arrivals") or [])[:1]:
71
+ who = [c for c in _fans(ch, inv_view, n["name"])
72
+ if inventory.key(n["name"]) not in {inventory.key(i) for i in ch.get("_bought", {}).get(c, {})}]
73
+ if len(who) >= MIN_AUDIENCE:
74
+ out.append({"key": f"new_arrival:{inventory.key(n['name'])}", "goal": "custom",
75
+ "title": f"Tell {len(who)} people about the new {n['name']}",
76
+ "why": "They buy this kind of thing and haven't tried it yet.",
77
+ "audience_ids": who, "audience_count": len(who), "offer": "", "item": n["name"]})
78
+ for x in (ch.get("cross_sell") or [])[:1]:
79
+ if inventory.blocked(inv_view, x["to"]):
80
+ continue
81
+ who = [c for c, bought in (ch.get("_bought") or {}).items() if x["from"] in bought and x["to"] not in bought]
82
+ if len(who) >= MIN_AUDIENCE:
83
+ out.append({"key": f"cross_sell:{inventory.key(x['from'])}>{inventory.key(x['to'])}", "goal": "custom",
84
+ "title": f"Suggest {x['to']} to {len(who)} people who buy {x['from']}",
85
+ "why": f"{x['also_buy_pct']:g}% of {x['from']} buyers also buy {x['to']}.",
86
+ "audience_ids": who, "audience_count": len(who), "offer": "", "item": x["to"]})
87
+ r = referrers or {}
88
+ if r.get("count", 0) >= MIN_AUDIENCE:
89
+ ids = [c["id"] for c in r["customers"]]
90
+ out.append({"key": "referral", "goal": "custom", "title": f"Ask {len(ids)} loyal regulars to bring a friend",
91
+ "why": "They visit often, recently, and are likely to stay.", "audience_ids": ids,
92
+ "audience_count": len(ids), "offer": ""})
93
+ out.sort(key=_rank)
94
+ return out[:limit]
95
+
96
+
97
+ def _rank(a: dict) -> tuple:
98
+ """Actions with an expected extra revenue first (largest first), then stock actions by the money
99
+ tied up, then the rest: the two numbers measure different things, so they are never compared."""
100
+ if a.get("expected_extra_revenue") is not None:
101
+ return (0, -a["expected_extra_revenue"])
102
+ if a.get("money_at_stake"):
103
+ return (1, -a["money_at_stake"])
104
+ return (2, -a.get("audience_count", 0))
vertiq/affinity.py ADDED
@@ -0,0 +1,147 @@
1
+ """Customer choices from line items: what sells together (combos), what a customer would likely
2
+ try next (cross-sell), and items people buy again on a cycle (refills).
3
+
4
+ combos two items on the same bill far more often than chance (lift), for combo offers
5
+ cross_sell people who buy A tend to buy B too; the people with A and never B are the audience
6
+ next_items per customer: what they haven't tried that people like them buy
7
+ refills items bought again on a steady cycle, and who is due now
8
+
9
+ Lift = how many times more often two things go together than if choices were independent:
10
+ 1.0 is chance, 2.0 is twice as often. Only pairs seen at least MIN_PAIR times count, so a single
11
+ big order never makes a pattern. Plain data in: [(customer id, bill id, when, item name)].
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from collections import Counter, defaultdict
16
+ from datetime import datetime, timedelta
17
+ from itertools import combinations
18
+ from statistics import median
19
+
20
+ MIN_PAIR = 5
21
+ MIN_BASKETS = 30
22
+ MAX_ITEMS_PER_BILL = 30
23
+
24
+
25
+ def clean_item(name) -> str:
26
+ """An item name as it should be compared: spaces collapsed, placeholders ("item") dropped."""
27
+ n = " ".join(str(name or "").split())[:60]
28
+ return "" if n.lower() in ("", "item") else n
29
+
30
+
31
+ def analyse(lines, min_pair: int = MIN_PAIR) -> dict:
32
+ """Everything affinity knows from [(customer id, bill id, when, item name)]."""
33
+ bills: dict[str, set] = defaultdict(set)
34
+ owner: dict[str, str] = {}
35
+ bought: dict[str, dict[str, list]] = defaultdict(lambda: defaultdict(list)) # cid -> item -> [dates]
36
+ for cid, tid, at, item in lines:
37
+ bills[tid].add(item)
38
+ owner[tid] = cid
39
+ bought[cid][item].append(at)
40
+ n_bills = len(bills)
41
+ result = {"bills": n_bills, "customers": len(bought), "status": "ok", "combos": [], "cross_sell": [],
42
+ "refills": [], "top_items": [], "_lifts": {}, "_bought": {}, "_cycles": {}}
43
+ if n_bills < MIN_BASKETS:
44
+ result["status"] = "learning"
45
+ return result
46
+ item_bills: Counter = Counter()
47
+ pair_bills: Counter = Counter()
48
+ for items in bills.values():
49
+ items = sorted(items)[:MAX_ITEMS_PER_BILL]
50
+ item_bills.update(items)
51
+ pair_bills.update(combinations(items, 2))
52
+ n_cust = len(bought)
53
+ item_cust = Counter(i for items in bought.values() for i in items)
54
+ pair_cust: Counter = Counter()
55
+ for items in bought.values():
56
+ pair_cust.update(combinations(sorted(items)[:MAX_ITEMS_PER_BILL * 3], 2))
57
+ result["top_items"] = [{"item": i, "customers": c, "share": round(100 * c / n_cust, 1)}
58
+ for i, c in item_cust.most_common(8)]
59
+ result["combos"] = _combos(pair_bills, item_bills, n_bills, min_pair)
60
+ lifts, cross = _cross_sell(pair_cust, item_cust, n_cust, bought, min_pair)
61
+ result["cross_sell"], result["_lifts"] = cross, lifts
62
+ result["_bought"] = {cid: {i: max(ds) for i, ds in items.items()} for cid, items in bought.items()}
63
+ result["refills"], result["_cycles"] = _refills(bought)
64
+ return result
65
+
66
+
67
+ def _combos(pair_bills: Counter, item_bills: Counter, n: int, min_pair: int = 5) -> list[dict]:
68
+ out = []
69
+ for (a, b), together in pair_bills.items():
70
+ if together < min_pair:
71
+ continue
72
+ lift = together * n / (item_bills[a] * item_bills[b])
73
+ if lift < 1.3:
74
+ continue
75
+ out.append({"items": [a, b], "bills_together": together, "lift": round(lift, 2),
76
+ "a_then_b": round(100 * together / item_bills[a], 1),
77
+ "b_then_a": round(100 * together / item_bills[b], 1)})
78
+ out.sort(key=lambda c: (-c["lift"] * min(c["bills_together"], 50), c["items"]))
79
+ return out[:10]
80
+
81
+
82
+ def _cross_sell(pair_cust: Counter, item_cust: Counter, n: int, bought: dict, min_pair: int = 5):
83
+ lifts: dict[str, dict[str, float]] = defaultdict(dict)
84
+ pairs = []
85
+ for (a, b), both in pair_cust.items():
86
+ if both < min_pair:
87
+ continue
88
+ lift = both * n / (item_cust[a] * item_cust[b])
89
+ if lift < 1.2:
90
+ continue
91
+ lifts[a][b] = lifts[b][a] = round(lift, 3)
92
+ for x, y in ((a, b), (b, a)):
93
+ pairs.append({"from": x, "to": y, "lift": round(lift, 2),
94
+ "also_buy_pct": round(100 * both / item_cust[x], 1),
95
+ "audience": item_cust[x] - both})
96
+ pairs.sort(key=lambda p: (-(p["lift"] * min(p["audience"], 100)), p["from"], p["to"]))
97
+ return {k: dict(v) for k, v in lifts.items()}, pairs[:10]
98
+
99
+
100
+ def _refills(bought: dict):
101
+ """Items people buy again on a steady cycle: each repeat buyer's median gap, then the median
102
+ across buyers. Needs 5 repeat buyers. Returns (summary, {item: cycle days})."""
103
+ gaps_by_item: dict[str, list] = defaultdict(list)
104
+ for items in bought.values():
105
+ for item, dates in items.items():
106
+ days = sorted({d.date() for d in dates})
107
+ gaps = [(days[i] - days[i - 1]).days for i in range(1, len(days))]
108
+ if gaps:
109
+ gaps_by_item[item].append(median(gaps))
110
+ cycles = {i: float(median(g)) for i, g in gaps_by_item.items() if len(g) >= 5 and median(g) >= 3}
111
+ summary = sorted(({"item": i, "cycle_days": round(c), "repeat_buyers": len(gaps_by_item[i])}
112
+ for i, c in cycles.items()), key=lambda r: -r["repeat_buyers"])
113
+ return summary[:10], cycles
114
+
115
+
116
+ def next_items(a: dict, customer_id: str, limit: int = 3) -> list[dict]:
117
+ """What this customer hasn't tried, ranked by how strongly their own items point to it."""
118
+ mine = a["_bought"].get(customer_id) or {}
119
+ scores: dict[str, float] = {}
120
+ because: dict[str, str] = {}
121
+ for item in mine:
122
+ for other, lift in (a["_lifts"].get(item) or {}).items():
123
+ if other in mine:
124
+ continue
125
+ if lift > scores.get(other, 0):
126
+ scores[other], because[other] = lift, item
127
+ ranked = sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))[:limit]
128
+ return [{"item": i, "lift": round(s, 2), "because": because[i], "confident": s >= 1.5} for i, s in ranked]
129
+
130
+
131
+ def due_refills(a: dict, customer_id: str, today: datetime, early: int = 3, late: int = 10) -> list[dict]:
132
+ """Items on a cycle this customer is due to buy again (from `early` days before to `late` after)."""
133
+ out = []
134
+ for item, last in (a["_bought"].get(customer_id) or {}).items():
135
+ cycle = a["_cycles"].get(item)
136
+ if not cycle:
137
+ continue
138
+ due = last + timedelta(days=cycle)
139
+ off = (today - due).days
140
+ if -early <= off <= late:
141
+ out.append({"item": item, "cycle_days": round(cycle), "days_from_due": off})
142
+ return sorted(out, key=lambda r: abs(r["days_from_due"]))
143
+
144
+
145
+ def public(a: dict) -> dict:
146
+ """The analysis without its private lookup tables, for the API."""
147
+ return {k: v for k, v in a.items() if not k.startswith("_")}
vertiq/autotune.py ADDED
@@ -0,0 +1,123 @@
1
+ """A gated search over task settings and candidate features: try one change at a time, keep it only if
2
+ it improves the validation score, and let the evaluation gate judge the final configuration.
3
+
4
+ Every trial is scored on validation splits carved from the training entities only (entities the gate
5
+ holds out are removed before the search starts), so the gate's held-out score stays unbiased. The final
6
+ configuration is trained by the normal task and must beat the baseline rule and the untuned
7
+ configuration on the held-out entities, at the usual confidence; otherwise the untuned one is kept.
8
+
9
+ result = autotune.search(ds, target="breakdown", window_days=30,
10
+ options={"learner": ["logistic", "boosted"], "survival": [False, True]},
11
+ features={"skipped_pm_60": skipped_pm_60, "shift_pattern": shift_pattern})
12
+ result["task"], result["config"], result["trials"]
13
+
14
+ This is the loop sometimes described as automated research (propose a change, measure, keep or revert),
15
+ applied with a held-out gate so that repeated trials cannot overfit the score that decides promotion.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import math
20
+
21
+ from . import harness, plugins
22
+ from .contract import Dataset
23
+ from .tasks import predict_event, predict_value
24
+
25
+ PACK = "__autotune__"
26
+
27
+
28
+ def _subset(ds: Dataset, keep: set[str]) -> Dataset:
29
+ return Dataset([e for e in ds.entities if e.id in keep], [e for e in ds.events if e.entity in keep])
30
+
31
+
32
+ def _fold(key: str, folds: int) -> int:
33
+ return int(harness.hashlib.md5((key + "#tune").encode(), usedforsecurity=False).hexdigest(), 16) % folds
34
+
35
+
36
+ def _run(ds: Dataset, kind: str, target: str, window_days: int, config: dict, chosen: list[str], features: dict,
37
+ pack: str | None, previous=None, confidence=harness.CONFIDENCE, holdout=None):
38
+ plugins.clear(PACK)
39
+ for fn in plugins.features_for(pack):
40
+ plugins.feature(PACK)(fn)
41
+ for name in chosen:
42
+ plugins.feature(PACK)(features[name])
43
+ kw = {k: v for k, v in config.items() if not (kind == "value" and k == "survival")}
44
+ task_fn = predict_event if kind == "event" else predict_value
45
+ try:
46
+ return task_fn(ds, target=target, window_days=window_days, pack=PACK, previous=previous,
47
+ confidence=confidence, holdout=holdout, **kw)
48
+ finally:
49
+ plugins.clear(PACK)
50
+
51
+
52
+ def _score(task, kind: str) -> float | None:
53
+ m = task.report().get("metrics") or {}
54
+ if kind == "event":
55
+ return m.get("auc")
56
+ return -m["mae"] if m.get("mae") is not None else None
57
+
58
+
59
+ def search(ds: Dataset, *, target: str, window_days: int, kind: str = "event", pack: str | None = None,
60
+ base: dict | None = None, options: dict | None = None, features: dict | None = None,
61
+ folds: int = 5, min_step: float = 0.003, agree: int | None = None, rounds: int = 2) -> dict:
62
+ """Greedy search from `base` (task keyword arguments, e.g. {"learner": "logistic"}) over `options`
63
+ ({argument: [values]}) and candidate plugin `features` ({name: fn(entity, before, cut)}), plus the
64
+ features already registered under `pack`. The training entities are split into `folds` folds, and
65
+ each fold in turn is the validation set. A change is kept when the mean validation score rises by at
66
+ least `min_step` and the score rises in at least `agree` folds (default: all but one), so a gain
67
+ that comes from one lucky fold is not kept (AUC for event tasks; minus the mean absolute error for
68
+ value tasks). Returns {"task" (the tuned task if the gate
69
+ promoted it over the untuned one, else the untuned task), "tuned_promoted", "tuned", "untuned",
70
+ "config", "features", "validation_score", "trials" (every change tried, with its score)}."""
71
+ base = dict(base or {"learner": "logistic" if kind == "event" else "linear"})
72
+ options, features = options or {}, features or {}
73
+ agree = folds - 1 if agree is None else agree
74
+ train = _subset(ds, {e.id for e in ds.entities if not harness.held_out(e.id)})
75
+
76
+ def evaluate(config, chosen):
77
+ scores = [_score(_run(train, kind, target, window_days, config, chosen, features, pack, confidence=None,
78
+ holdout=lambda key, k=k: _fold(key, folds) == k), kind) for k in range(folds)]
79
+ return None if any(x is None for x in scores) else scores
80
+
81
+ def mean(scores):
82
+ return sum(scores) / len(scores) if scores else -math.inf
83
+
84
+ config, chosen = dict(base), []
85
+ best_folds = evaluate(config, chosen)
86
+ best = mean(best_folds)
87
+ trials = [{"change": "start", "score": _r(best), "kept": True}]
88
+ for _round in range(rounds):
89
+ improved = False
90
+ moves = [(k, v) for k, vals in options.items() for v in vals]
91
+ moves += [("+feature", n) for n in features] + [("-feature", n) for n in features]
92
+ for key, val in moves:
93
+ if key == "+feature" and val in chosen or key == "-feature" and val not in chosen:
94
+ continue
95
+ if key not in ("+feature", "-feature") and config.get(key) == val:
96
+ continue
97
+ cfg, ch = dict(config), list(chosen)
98
+ if key == "+feature":
99
+ ch.append(val)
100
+ elif key == "-feature":
101
+ ch.remove(val)
102
+ else:
103
+ cfg[key] = val
104
+ f = evaluate(cfg, ch)
105
+ s = mean(f)
106
+ wins = sum(a > b for a, b in zip(f, best_folds)) if f and best_folds else 0
107
+ keep = s >= best + min_step and wins >= agree
108
+ trials.append({"change": f"{key} {val}" if key.endswith("feature") else f"{key}={val}",
109
+ "score": _r(s), "folds_improved": f"{wins}/{folds}", "kept": keep})
110
+ if keep:
111
+ config, chosen, best, best_folds, improved = cfg, ch, s, f, True
112
+ if not improved:
113
+ break
114
+ untuned = _run(ds, kind, target, window_days, base, [], features, pack)
115
+ final = (_run(ds, kind, target, window_days, config, chosen, features, pack, previous=untuned.model)
116
+ if (config, chosen) != (base, []) else untuned)
117
+ use = final if final is not untuned and final.status == "trained" else untuned
118
+ return {"task": use, "tuned_promoted": use is final and final is not untuned, "tuned": final, "untuned": untuned,
119
+ "config": config, "features": chosen, "validation_score": _r(best), "trials": trials}
120
+
121
+
122
+ def _r(x):
123
+ return round(x, 4) if x not in (None, -math.inf) else None
vertiq/boost.py ADDED
@@ -0,0 +1,198 @@
1
+ """Gradient-boosted decision trees in pure Python: a learner for signals a linear model cannot express,
2
+ such as thresholds and interactions ("risk rises only when maintenance was skipped *and* the asset is
3
+ old"). Registered as "boosted" (classification, logistic loss) and "boosted_regression" (squared loss).
4
+
5
+ Each tree is fitted to the gradient of the loss with second-order (Newton) leaf values, on quantile
6
+ bins of each feature, with row subsampling. Explanations follow each tree's path: a feature's
7
+ contribution is the change in the node value at every split on that feature (the Saabas method), so
8
+ a score lists what raised and lowered it, as the linear models do.
9
+
10
+ A model is a plain dict (`to_dict`), like every other Vertiq model.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import bisect
15
+ import math
16
+ import random
17
+
18
+ from .learn import Linear, Logistic, logit, sigmoid
19
+
20
+ MAX_BINS = 16
21
+
22
+
23
+ class BoostedClassifier:
24
+ """Boosted trees on {feature name: value} rows, logistic loss. Output: a probability."""
25
+
26
+ loss = "logistic"
27
+
28
+ # defaults chosen on the benchmark scenarios: shallow trees with large leaves generalise better on the
29
+ # few-thousand-row datasets typical of one organisation's history than deeper ones
30
+ def __init__(self, names: list[str] | None = None, *, trees: int = 60, depth: int = 2, rate: float = 0.1,
31
+ min_leaf: int = 40, l2: float = 1.0, subsample: float = 0.8, seed: int = 7):
32
+ self.names: list[str] = list(names or [])
33
+ self.trees_n, self.depth, self.rate, self.min_leaf, self.l2 = trees, depth, rate, min_leaf, l2
34
+ self.subsample, self.seed = subsample, seed
35
+ self.cuts: dict[str, list[float]] = {}
36
+ self.fill: dict[str, float] = {}
37
+ self.base: float = 0.0
38
+ self.trees: list[dict] = []
39
+ self.offsets: dict[str, float] = {}
40
+
41
+ # ---- fitting -------------------------------------------------------------------------------
42
+ def _start(self, y: list) -> float:
43
+ return logit(min(1 - 1e-3, max(1e-3, sum(y) / len(y))))
44
+
45
+ def _grad(self, raw: float, y: float) -> tuple[float, float]:
46
+ p = sigmoid(raw)
47
+ return p - y, max(p * (1 - p), 1e-6)
48
+
49
+ def _value(self, raw: float) -> float:
50
+ return sigmoid(raw)
51
+
52
+ def _bins(self, rows: list[dict]) -> list[list[int]]:
53
+ if not self.names:
54
+ self.names = sorted({k for r in rows for k in r})
55
+ cols = []
56
+ for k in self.names:
57
+ vals = sorted(float(r.get(k, 0.0)) for r in rows)
58
+ self.fill[k] = vals[len(vals) // 2]
59
+ uniq = sorted(set(vals))
60
+ if len(uniq) <= MAX_BINS:
61
+ cuts = uniq[:-1]
62
+ else:
63
+ cuts = sorted({vals[(i * len(vals)) // MAX_BINS] for i in range(1, MAX_BINS)})
64
+ self.cuts[k] = cuts
65
+ cols.append([bisect.bisect_left(cuts, float(r.get(k, self.fill[k]))) for r in rows])
66
+ return cols
67
+
68
+ def _tree(self, cols, idx, g, h, depth) -> dict:
69
+ G, H = sum(g[i] for i in idx), sum(h[i] for i in idx)
70
+ node = {"v": -G / (H + self.l2)}
71
+ if depth == self.depth or len(idx) < 2 * self.min_leaf:
72
+ return node
73
+ parent = G * G / (H + self.l2)
74
+ best = (1e-9, None, None)
75
+ for j, col in enumerate(cols):
76
+ nb = len(self.cuts[self.names[j]]) + 1
77
+ if nb < 2:
78
+ continue
79
+ gb, hb, cb = [0.0] * nb, [0.0] * nb, [0] * nb
80
+ for i in idx:
81
+ b = col[i]
82
+ gb[b] += g[i]
83
+ hb[b] += h[i]
84
+ cb[b] += 1
85
+ gl = hl = 0.0
86
+ cl = 0
87
+ for b in range(nb - 1):
88
+ gl, hl, cl = gl + gb[b], hl + hb[b], cl + cb[b]
89
+ cr = len(idx) - cl
90
+ if cl < self.min_leaf or cr < self.min_leaf:
91
+ continue
92
+ gain = gl * gl / (hl + self.l2) + (G - gl) ** 2 / (H - hl + self.l2) - parent
93
+ if gain > best[0]:
94
+ best = (gain, j, b)
95
+ _gain, j, b = best
96
+ if j is None:
97
+ return node
98
+ left = [i for i in idx if cols[j][i] <= b]
99
+ right = [i for i in idx if cols[j][i] > b]
100
+ node.update(f=self.names[j], t=self.cuts[self.names[j]][b], j=j, b=b,
101
+ l=self._tree(cols, left, g, h, depth + 1), r=self._tree(cols, right, g, h, depth + 1))
102
+ return node
103
+
104
+ @staticmethod
105
+ def _walk_bins(node: dict, cols, i: int) -> float:
106
+ while "f" in node:
107
+ node = node["l"] if cols[node["j"]][i] <= node["b"] else node["r"]
108
+ return node["v"]
109
+
110
+ def fit(self, rows: list[dict], y: list, **_unused) -> "BoostedClassifier":
111
+ if len(rows) != len(y) or not rows:
112
+ raise ValueError("need matching, non-empty rows and labels")
113
+ cols = self._bins(rows)
114
+ n = len(rows)
115
+ self.base = self._start(y)
116
+ raw = [self.base] * n
117
+ rnd = random.Random(self.seed)
118
+ self.trees = []
119
+ for _ in range(self.trees_n):
120
+ gh = [self._grad(raw[i], y[i]) for i in range(n)]
121
+ g, h = [a for a, _ in gh], [b for _, b in gh]
122
+ idx = [i for i in range(n) if rnd.random() < self.subsample] or list(range(n))
123
+ tree = self._tree(cols, idx, g, h, 0)
124
+ self.trees.append(tree)
125
+ for i in range(n):
126
+ raw[i] += self.rate * self._walk_bins(tree, cols, i)
127
+ for t in self.trees: # binned indices are a training aid only
128
+ _strip(t)
129
+ return self
130
+
131
+ def fit_offsets(self, rows: list[dict], y: list, groups: list[str], *, prior: float = 200.0) -> None:
132
+ """A shift per group on the raw scale, shrunk toward 0 (as the linear models do)."""
133
+ Logistic.fit_offsets(self, rows, y, groups, prior=prior)
134
+
135
+ # ---- scoring -------------------------------------------------------------------------------
136
+ def _x(self, row: dict, k: str) -> float:
137
+ return float(row.get(k, self.fill.get(k, 0.0)))
138
+
139
+ def raw(self, row: dict) -> float:
140
+ z = self.base
141
+ for t in self.trees:
142
+ node = t
143
+ while "f" in node:
144
+ node = node["l"] if self._x(row, node["f"]) <= node["t"] else node["r"]
145
+ z += self.rate * node["v"]
146
+ return z
147
+
148
+ def predict(self, row: dict, group: str | None = None) -> float:
149
+ return self._value(self.raw(row) + (self.offsets.get(group, 0.0) if group else 0.0))
150
+
151
+ def contributions(self, row: dict) -> list[tuple[str, float]]:
152
+ """Each feature's push on the raw score for this row, largest first (signed)."""
153
+ c: dict[str, float] = {}
154
+ for t in self.trees:
155
+ node = t
156
+ while "f" in node:
157
+ nxt = node["l"] if self._x(row, node["f"]) <= node["t"] else node["r"]
158
+ c[node["f"]] = c.get(node["f"], 0.0) + self.rate * (nxt["v"] - node["v"])
159
+ node = nxt
160
+ return sorted(c.items(), key=lambda kv: -abs(kv[1]))
161
+
162
+ # ---- storage -------------------------------------------------------------------------------
163
+ def to_dict(self) -> dict:
164
+ return {"kind": self.loss, "names": self.names, "base": self.base, "rate": self.rate, "fill": self.fill,
165
+ "trees": self.trees, "offsets": self.offsets}
166
+
167
+ @classmethod
168
+ def from_dict(cls, d: dict):
169
+ m = cls(d.get("names") or [])
170
+ m.base, m.rate, m.fill = float(d["base"]), float(d["rate"]), dict(d.get("fill") or {})
171
+ m.trees, m.offsets = list(d["trees"]), dict(d.get("offsets") or {})
172
+ return m
173
+
174
+
175
+ class BoostedRegressor(BoostedClassifier):
176
+ """Boosted trees with squared loss. Output: the raw value (e.g. the log of a bill)."""
177
+
178
+ loss = "squared"
179
+
180
+ def _start(self, y: list) -> float:
181
+ return sum(y) / len(y)
182
+
183
+ def _grad(self, raw: float, y: float) -> tuple[float, float]:
184
+ return raw - y, 1.0
185
+
186
+ def _value(self, raw: float) -> float:
187
+ return raw
188
+
189
+ def fit_offsets(self, rows, y, groups, *, prior: float = 200.0) -> None:
190
+ Linear.fit_offsets(self, rows, y, groups, prior=prior)
191
+
192
+
193
+ def _strip(node: dict) -> None:
194
+ node.pop("j", None)
195
+ node.pop("b", None)
196
+ if "l" in node:
197
+ _strip(node["l"])
198
+ _strip(node["r"])