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 +23 -0
- vertiq/actions.py +104 -0
- vertiq/affinity.py +147 -0
- vertiq/autotune.py +123 -0
- vertiq/boost.py +198 -0
- vertiq/category.py +144 -0
- vertiq/causal.py +112 -0
- vertiq/checkout.py +52 -0
- vertiq/churn.py +206 -0
- vertiq/context.py +96 -0
- vertiq/contract.py +72 -0
- vertiq/dataset.py +120 -0
- vertiq/design.py +57 -0
- vertiq/diagnose.py +89 -0
- vertiq/drift.py +87 -0
- vertiq/experiments.py +25 -0
- vertiq/features.py +109 -0
- vertiq/guard.py +87 -0
- vertiq/harness.py +175 -0
- vertiq/insights.py +219 -0
- vertiq/inventory.py +152 -0
- vertiq/learn.py +248 -0
- vertiq/mapping.py +535 -0
- vertiq/menu.py +176 -0
- vertiq/narrate.py +266 -0
- vertiq/offers.py +49 -0
- vertiq/patterns.py +198 -0
- vertiq/plan.py +101 -0
- vertiq/plugins.py +72 -0
- vertiq/profiles.py +31 -0
- vertiq/report.py +207 -0
- vertiq/research.py +134 -0
- vertiq/rules.py +106 -0
- vertiq/sequences.py +44 -0
- vertiq/sequential.py +115 -0
- vertiq/summary.py +67 -0
- vertiq/survival.py +82 -0
- vertiq/tables.py +333 -0
- vertiq/tasks.py +342 -0
- vertiq/trends.py +48 -0
- vertiq/uplift.py +233 -0
- vertiq/value.py +89 -0
- vertiq/verticals.py +48 -0
- vertiq-0.2.0.dist-info/METADATA +452 -0
- vertiq-0.2.0.dist-info/RECORD +49 -0
- vertiq-0.2.0.dist-info/WHEEL +5 -0
- vertiq-0.2.0.dist-info/entry_points.txt +2 -0
- vertiq-0.2.0.dist-info/licenses/LICENSE +202 -0
- vertiq-0.2.0.dist-info/top_level.txt +1 -0
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"])
|