upliftlab 0.26.1__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.
- upliftlab/__init__.py +5 -0
- upliftlab/__main__.py +119 -0
- upliftlab/algorithms/__init__.py +6 -0
- upliftlab/algorithms/dml.py +35 -0
- upliftlab/algorithms/duality_r_learner.py +86 -0
- upliftlab/algorithms/lite/dml.py +53 -0
- upliftlab/algorithms/lite/duality_r_learner.py +78 -0
- upliftlab/algorithms/lite/r_learner.py +43 -0
- upliftlab/algorithms/lite/t_learner.py +33 -0
- upliftlab/algorithms/lite/x_learner.py +42 -0
- upliftlab/algorithms/r_learner.py +45 -0
- upliftlab/algorithms/random_baseline.py +15 -0
- upliftlab/algorithms/t_learner.py +28 -0
- upliftlab/algorithms/x_learner.py +34 -0
- upliftlab/arena.py +473 -0
- upliftlab/bridge.py +208 -0
- upliftlab/contract.py +254 -0
- upliftlab/datasets.py +330 -0
- upliftlab/metrics.py +65 -0
- upliftlab/scenario.py +132 -0
- upliftlab/server.py +134 -0
- upliftlab/templates/start_lightgbm.py +34 -0
- upliftlab/templates/start_pytorch.py +52 -0
- upliftlab/templates/start_sklearn.py +43 -0
- upliftlab/ui/app.js +57 -0
- upliftlab/ui/catalog.js +3 -0
- upliftlab/ui/charts.js +115 -0
- upliftlab/ui/data/LICENSE-criteo.md +14 -0
- upliftlab/ui/editor-entry.js +32 -0
- upliftlab/ui/engine-files.js +2 -0
- upliftlab/ui/fonts/LICENSE +198 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.0.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.1.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.10.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.11.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.12.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.13.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.14.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.15.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.16.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.17.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.18.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.19.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.2.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.20.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.21.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.22.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.23.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.24.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.25.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.26.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.27.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.28.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.29.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.3.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.30.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.31.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.32.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.33.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.34.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.35.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.36.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.37.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.38.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.39.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.4.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.40.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.41.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.42.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.43.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.44.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.45.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.46.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.47.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.48.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.49.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.5.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.50.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.51.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.52.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.53.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.54.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.55.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.56.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.57.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.58.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.59.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.6.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.60.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.61.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.62.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.63.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.64.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.65.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.66.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.67.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.68.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.69.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.7.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.70.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.71.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.72.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.73.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.74.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.75.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.76.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.77.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.78.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.79.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.8.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.80.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.81.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.82.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.83.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.84.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.85.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.86.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.87.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.88.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.89.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.9.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.90.woff2 +0 -0
- upliftlab/ui/fonts/files/PretendardVariable.subset.91.woff2 +0 -0
- upliftlab/ui/fonts/files/archivo-latin-ext-wdth-normal.woff2 +0 -0
- upliftlab/ui/fonts/files/archivo-latin-wdth-normal.woff2 +0 -0
- upliftlab/ui/fonts/files/geist-mono-latin-ext-wght-normal.woff2 +0 -0
- upliftlab/ui/fonts/files/geist-mono-latin-wght-normal.woff2 +0 -0
- upliftlab/ui/fonts/font.css +770 -0
- upliftlab/ui/index.html +24 -0
- upliftlab/ui/lib.js +91 -0
- upliftlab/ui/report.js +188 -0
- upliftlab/ui/runtime.js +117 -0
- upliftlab/ui/store.js +292 -0
- upliftlab/ui/styles.css +516 -0
- upliftlab/ui/summary.js +126 -0
- upliftlab/ui/vendor/LICENSES.txt +340 -0
- upliftlab/ui/vendor/editor.js +13 -0
- upliftlab/ui/views/algorithms.js +174 -0
- upliftlab/ui/views/data.js +141 -0
- upliftlab/ui/views/home.js +125 -0
- upliftlab/ui/views/launcher.js +134 -0
- upliftlab/ui/views/results.js +313 -0
- upliftlab/ui/views/workspace.js +53 -0
- upliftlab-0.26.1.dist-info/METADATA +147 -0
- upliftlab-0.26.1.dist-info/RECORD +152 -0
- upliftlab-0.26.1.dist-info/WHEEL +5 -0
- upliftlab-0.26.1.dist-info/entry_points.txt +2 -0
- upliftlab-0.26.1.dist-info/licenses/LICENSE +21 -0
- upliftlab-0.26.1.dist-info/licenses/web/data/LICENSE-criteo.md +14 -0
- upliftlab-0.26.1.dist-info/licenses/web/fonts/LICENSE +198 -0
- upliftlab-0.26.1.dist-info/licenses/web/vendor/LICENSES.txt +340 -0
- upliftlab-0.26.1.dist-info/top_level.txt +1 -0
upliftlab/__init__.py
ADDED
upliftlab/__main__.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
parser = argparse.ArgumentParser(prog='upliftlab', description='upliftlab local edition: the same UI and engine as uplift-lab.vercel.app, on your own Python. Run it with no command to open the UI.')
|
|
7
|
+
sub = parser.add_subparsers(dest='command', metavar='{serve,run,check}')
|
|
8
|
+
web = sub.add_parser('serve', help='open the web UI on localhost and run the engine natively (the default)')
|
|
9
|
+
web.add_argument('--host', default='127.0.0.1')
|
|
10
|
+
web.add_argument('--port', type=int, default=4180)
|
|
11
|
+
web.add_argument('--web', help='folder with the UI (defaults to web/ in a source checkout, else the UI inside the package)')
|
|
12
|
+
web.add_argument('--no-browser', action='store_true', help='do not open the browser')
|
|
13
|
+
check = sub.add_parser('check', help='list the libraries an algorithm file uses, then fit it once on synthetic data')
|
|
14
|
+
check.add_argument('file', metavar='FILE.py')
|
|
15
|
+
run = sub.add_parser('run', help='run a head-to-head comparison without the UI and write the result as JSON')
|
|
16
|
+
run.add_argument('--data', default='public/criteo', help='public/criteo (the bundled 200k-row sample) | synthetic/base | path to a CSV')
|
|
17
|
+
run.add_argument('--algorithms', default='all', help='comma-separated built-in ids, or "all"')
|
|
18
|
+
run.add_argument('--algorithm', action='append', default=[], metavar='FILE.py', help='add your own algorithm file (repeatable)')
|
|
19
|
+
run.add_argument('--cycles', type=int, default=8)
|
|
20
|
+
run.add_argument('--explore', type=float, default=.2)
|
|
21
|
+
run.add_argument('--target', type=float, default=.4)
|
|
22
|
+
run.add_argument('--seed', type=int, default=42)
|
|
23
|
+
run.add_argument('--mode', choices=['quantile', 'budget', 'knapsack', 'roi', 'validation'], default='budget', help='budget (default): by score until the budget runs out. quantile: the top q%%. knapsack: value - price * cost > 0, priced to the budget. roi: by value / cost while the expected efficiency meets --efficiency. validation: up to the share where uplift peaked on validation rows')
|
|
24
|
+
run.add_argument('--budget', type=float, default=.4, help='budget as a share of the expected cost of treating every candidate')
|
|
25
|
+
run.add_argument('--efficiency', type=float, default=.2, help='with --mode roi: required efficiency gain over random selection')
|
|
26
|
+
run.add_argument('--base-learner', choices=['ridge', 'tree'], default='ridge', help='base model of the built-in algorithms')
|
|
27
|
+
run.add_argument('--out', help='write the result JSON here (open it in the web UI under 실행 기록 → 결과 JSON 열기)')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main(argv=None):
|
|
31
|
+
args = parser.parse_args(argv)
|
|
32
|
+
if args.command is None: # plain `upliftlab` opens the UI
|
|
33
|
+
args = parser.parse_args(['serve'])
|
|
34
|
+
if args.command == 'serve':
|
|
35
|
+
from .server import serve
|
|
36
|
+
serve(args.host, args.port, args.web, open_browser=not args.no_browser)
|
|
37
|
+
elif args.command == 'check':
|
|
38
|
+
from . import bridge
|
|
39
|
+
from .server import detect_devices
|
|
40
|
+
devices = detect_devices()
|
|
41
|
+
bridge.state.update(runtime='local', device=devices[0], devices=devices)
|
|
42
|
+
path = Path(args.file)
|
|
43
|
+
code = path.read_text(encoding='utf-8')
|
|
44
|
+
out = bridge.check(code, path.stem)
|
|
45
|
+
print(f"{path.name} · {out.get('name') or path.stem}")
|
|
46
|
+
shown = {'ok': lambda l: f"{l['package']} {l['version'] or ''}".strip(), 'missing': lambda l: f"{l['package']} (missing · pip install {l['package']})"}
|
|
47
|
+
print('libraries: ' + (' · '.join(shown.get(l['status'], shown['missing'])(l) for l in out['libraries']) or 'standard library only'))
|
|
48
|
+
if out['ok']:
|
|
49
|
+
out = bridge.try_algorithm(code, path.stem, out.get('name'))
|
|
50
|
+
if not out['ok']:
|
|
51
|
+
sys.exit(f"{path}:{out.get('line') or '?'}: {out['error']}")
|
|
52
|
+
print(f"quick check: ok · test AUCC truth {out['auccTruth']:.3f} · observed {out['aucc']:.3f} · fit {out['ms']:.0f} ms on {out['split']['train']:,} rows · device {devices[0]}")
|
|
53
|
+
else:
|
|
54
|
+
from . import bridge, datasets
|
|
55
|
+
from .server import detect_devices
|
|
56
|
+
devices = detect_devices()
|
|
57
|
+
bridge.state.update(runtime='local', device=devices[0], devices=devices)
|
|
58
|
+
builtins = {a['id']: a for a in bridge.builtins()}
|
|
59
|
+
ids = list(builtins) if args.algorithms == 'all' else [a for a in args.algorithms.split(',') if a]
|
|
60
|
+
unknown = [a for a in ids if a not in builtins]
|
|
61
|
+
if unknown:
|
|
62
|
+
sys.exit(f'unknown algorithm: {", ".join(unknown)} (choose from {", ".join(builtins)})')
|
|
63
|
+
algorithms = [{'id': a, 'name': builtins[a]['name'], 'code': builtins[a]['code']} for a in ids]
|
|
64
|
+
for file in args.algorithm:
|
|
65
|
+
code = Path(file).read_text(encoding='utf-8')
|
|
66
|
+
check = bridge.check(code, Path(file).stem)
|
|
67
|
+
if not check['ok']:
|
|
68
|
+
sys.exit(f'{file}:{check.get("line") or "?"}: {check["error"]}')
|
|
69
|
+
algorithms.append({'id': 'file-' + Path(file).stem, 'name': check.get('name') or Path(file).stem, 'code': code})
|
|
70
|
+
dataset = args.data
|
|
71
|
+
if dataset.startswith('public/'):
|
|
72
|
+
bridge.fetch_dataset(dataset)
|
|
73
|
+
elif not dataset.startswith('synthetic/'):
|
|
74
|
+
path = Path(dataset)
|
|
75
|
+
dataset = 'upload/' + path.name
|
|
76
|
+
bridge.add_dataset(dataset, path.read_text(encoding='utf-8'), path.name)
|
|
77
|
+
print(f'{dataset} · {len(algorithms)} algorithms · {args.cycles} cycles · device {devices[0]}', file=sys.stderr)
|
|
78
|
+
bridge.set_reporter(lambda m: (lambda d: d.get('phase') == 'done' and print(f" c{d['cycle']} {d['arm']} done", file=sys.stderr))(json.loads(m)))
|
|
79
|
+
result = bridge.run_all(dataset, algorithms, args.cycles, args.explore, args.target, args.seed, args.mode, args.budget, args.base_learner, args.efficiency)
|
|
80
|
+
by = result['summary']['by']
|
|
81
|
+
measure = ('incremental' if by.lower().endswith('incr') else 'gain') + (' (truth)' if by.startswith('truth') else ' (observed)')
|
|
82
|
+
volume = by.lower().endswith('incr') # ranked by incremental outcome: the band is the gap to first place, as on the screen
|
|
83
|
+
print(f"\n{'rank':<5}{'algorithm':<26}{measure:>22}{'vs 1st, 95% CI' if volume else 'observed 95% CI':>30}{'AUCC':>8}{'Qini':>8}{'fit ms':>9}")
|
|
84
|
+
for s in sorted(result['summary']['arms'], key=lambda s: s['rank']):
|
|
85
|
+
g, ci, auc, ms = s[by], s['ci'], s['aucc'], s['trainMs']
|
|
86
|
+
gain = '—' if g is None else f'{g:,.0f}' if volume else f'{g:+.1%}'
|
|
87
|
+
q = '—' if s['qini'] is None else f"{s['qini']:.3f}"
|
|
88
|
+
gap = s.get('vsLeader') or {}
|
|
89
|
+
if volume:
|
|
90
|
+
band = '1st' if s['rank'] == 1 else '—' if gap.get('diff') is None or gap.get('ci', [None])[0] is None else f"{gap['diff']:+,.0f} [{gap['ci'][0]:+,.0f}, {gap['ci'][1]:+,.0f}]"
|
|
91
|
+
else:
|
|
92
|
+
band = '—' if ci[0] is None else f'[{ci[0]:+.1%}, {ci[1]:+.1%}]'
|
|
93
|
+
auc = '—' if auc is None else f'{auc:.3f}'
|
|
94
|
+
ms = '—' if ms is None else f'{ms:.0f}'
|
|
95
|
+
print(f"{s['rank']:<5}{s['name'][:25]:<26}{gain:>22}{band:>30}{auc:>8}{q:>8}{ms:>9}")
|
|
96
|
+
print('\n' + conclusion(result, args.mode))
|
|
97
|
+
if args.out:
|
|
98
|
+
Path(args.out).write_text(json.dumps({'format': 'upliftlab.run/1', **result}, ensure_ascii=False))
|
|
99
|
+
print(f'\nwrote {args.out}', file=sys.stderr)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def conclusion(result, mode):
|
|
103
|
+
"""One line under the table: who won, by how much over Random, on which basis."""
|
|
104
|
+
arms, by = result['summary']['arms'], result['summary']['by']
|
|
105
|
+
lead = min(arms, key=lambda s: s['rank'])
|
|
106
|
+
random = next((s for s in arms if s['arm'] == 'random_baseline' and s is not lead), None)
|
|
107
|
+
basis = 'true effect, known for synthetic data' if by.startswith('truth') else 'observed: treated minus control'
|
|
108
|
+
if lead.get(by) is None:
|
|
109
|
+
return f"{lead['name']} ranks first ({basis})."
|
|
110
|
+
if by.lower().endswith('incr'):
|
|
111
|
+
line = f"{lead['name']} ranks first with {lead[by]:,.0f} incremental outcome"
|
|
112
|
+
if random and random.get(by):
|
|
113
|
+
line += f", {lead[by] - random[by]:+,.0f} ({lead[by] / random[by] - 1:+.0%}) over Random"
|
|
114
|
+
return line + (' for the same budget' if mode in ('budget', 'knapsack') else '') + f' ({basis}).'
|
|
115
|
+
return f"{lead['name']} ranks first with {lead[by]:+.1%} cost efficiency over random selection ({basis})."
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == '__main__':
|
|
119
|
+
main()
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Built-in algorithms. Each file is a self-contained `class Model` that imports common libraries, never upliftlab.
|
|
2
|
+
|
|
3
|
+
The files here use EconML (PyWhy) and run in the local edition. The browser has no EconML, so the web runs the files in
|
|
4
|
+
lite/: the same methods written with scikit-learn only. Random has no lite version because it needs neither.
|
|
5
|
+
The screen shows and runs these exact files."""
|
|
6
|
+
ORDER = ['random_baseline', 'x_learner', 't_learner', 'r_learner', 'dml', 'duality_r_learner']
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Y와 T의 잔차를 cross-fitting으로 구하고, 그 관계로 CATE를 추정해요(Double Machine Learning). 비용은 보지 않아요.
|
|
2
|
+
|
|
3
|
+
EconML의 LinearDML이에요. ỹ = y − m̂(x), t̃ = t − ê(x)를 3-fold cross-fitting으로 구하고 τ̂(x) = θ₀ + θ'x를 ỹ ≈ t̃·τ(x)로 맞춰요.
|
|
4
|
+
R-Learner와 달리 마지막 단계가 선형이라 해석하기 쉽고, 처치가 공변량에 따라 달라지는 로그에서도 ê(x)로 보정해요.
|
|
5
|
+
"""
|
|
6
|
+
import numpy as np
|
|
7
|
+
from sklearn.ensemble import HistGradientBoostingClassifier, HistGradientBoostingRegressor
|
|
8
|
+
from sklearn.linear_model import LogisticRegression, Ridge
|
|
9
|
+
from sklearn.pipeline import make_pipeline
|
|
10
|
+
from sklearn.preprocessing import StandardScaler
|
|
11
|
+
from econml.dml import LinearDML
|
|
12
|
+
|
|
13
|
+
NAME = 'DML'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def regressor(kind):
|
|
17
|
+
if kind == 'tree':
|
|
18
|
+
return HistGradientBoostingRegressor(max_depth=3, max_iter=100, early_stopping=False, random_state=0)
|
|
19
|
+
return make_pipeline(StandardScaler(), Ridge(alpha=1.0))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def classifier(kind):
|
|
23
|
+
if kind == 'tree':
|
|
24
|
+
return HistGradientBoostingClassifier(max_depth=3, max_iter=100, early_stopping=False, random_state=0)
|
|
25
|
+
return make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Model:
|
|
29
|
+
def fit(self, train, validation, context):
|
|
30
|
+
kind = getattr(context, 'base_learner', 'ridge')
|
|
31
|
+
self.est = LinearDML(model_y=regressor(kind), model_t=classifier(kind), discrete_treatment=True, cv=3, random_state=context.model_seed)
|
|
32
|
+
self.est.fit(train.y, train.t, X=train.X)
|
|
33
|
+
|
|
34
|
+
def predict(self, X):
|
|
35
|
+
return np.asarray(self.est.const_marginal_effect(X)).reshape(len(X), -1)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""성과와 비용의 CATE를 R-Learner로 학습하고 τ̂ʳ − λ·τ̂ᶜ 순으로 정렬해요. λ는 validation AUCC가 가장 높은 값으로 정해요.
|
|
2
|
+
|
|
3
|
+
τ̂ʳ, τ̂ᶜ는 y / σ_y, cost / σ_c의 CATE이고, EconML의 NonParamDML(R-Learner)로 학습해요.
|
|
4
|
+
σ_y, σ_c는 train의 표준편차예요. 정규화하므로 성과와 비용의 단위가 바뀌어도 순위가 같아요.
|
|
5
|
+
"""
|
|
6
|
+
import numpy as np
|
|
7
|
+
from sklearn.ensemble import HistGradientBoostingClassifier, HistGradientBoostingRegressor
|
|
8
|
+
from sklearn.linear_model import LogisticRegression, Ridge
|
|
9
|
+
from sklearn.pipeline import make_pipeline
|
|
10
|
+
from sklearn.preprocessing import StandardScaler
|
|
11
|
+
from econml.dml import NonParamDML
|
|
12
|
+
|
|
13
|
+
NAME = 'Duality R-Learner'
|
|
14
|
+
LAMBDAS = [0, 0.05, 0.1, 0.2, 0.4, 0.8, 1.6] # 정규화한 성과와 비용 사이의 가중치
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def regressor(kind):
|
|
18
|
+
if kind == 'tree':
|
|
19
|
+
return HistGradientBoostingRegressor(max_depth=3, max_iter=100, early_stopping=False, random_state=0)
|
|
20
|
+
return make_pipeline(StandardScaler(), Ridge(alpha=1.0))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def classifier(kind):
|
|
24
|
+
if kind == 'tree':
|
|
25
|
+
return HistGradientBoostingClassifier(max_depth=3, max_iter=100, early_stopping=False, random_state=0)
|
|
26
|
+
return make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def final(kind):
|
|
30
|
+
"""마지막 단계 회귀. EconML이 sample_weight를 넘기므로 파이프라인이 아닌 모델을 써요."""
|
|
31
|
+
if kind == 'tree':
|
|
32
|
+
return HistGradientBoostingRegressor(max_depth=3, max_iter=100, early_stopping=False, random_state=0)
|
|
33
|
+
return Ridge(alpha=1.0)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def r_learner(X, t, y, seed, kind):
|
|
37
|
+
est = NonParamDML(model_y=regressor(kind), model_t=classifier(kind), model_final=final(kind), discrete_treatment=True, cv=2, random_state=seed)
|
|
38
|
+
return est.fit(y, t, X=X)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def aucc(score, t, y, cost, points=20):
|
|
42
|
+
"""점수 순으로 상위 S를 골랐을 때 (증분 비용, 증분 성과) 곡선 아래 넓이를 0–1로 맞춘 값. 무작위 순서 ≈ 0.5."""
|
|
43
|
+
order = np.argsort(-score, kind='stable')
|
|
44
|
+
cut = np.round(np.arange(1, points + 1) * len(order) / points).astype(int) - 1
|
|
45
|
+
T = (t[order] > 0).astype(float)
|
|
46
|
+
nT, nC = np.cumsum(T)[cut], np.cumsum(1 - T)[cut]
|
|
47
|
+
ok = (nT > 0) & (nC > 0)
|
|
48
|
+
|
|
49
|
+
def lift(v):
|
|
50
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
51
|
+
return np.where(ok, nT * (np.cumsum(v[order] * T)[cut] / nT - np.cumsum(v[order] * (1 - T))[cut] / nC), 0.)
|
|
52
|
+
gain, spend = np.r_[0., lift(y)], np.r_[0., lift(cost)]
|
|
53
|
+
if gain.max() <= 0 or spend.max() <= 0:
|
|
54
|
+
return None
|
|
55
|
+
x, h = spend / spend.max(), gain / gain.max()
|
|
56
|
+
return float(np.sum(np.diff(x) * (h[1:] + h[:-1]) / 2))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Model:
|
|
60
|
+
score_is_effect = False # 점수에 비용이 이미 들어 있어서 효과로 읽지 않아요. Knapsack과 효율 제약 선정에서 이 순서를 그대로 써요.
|
|
61
|
+
|
|
62
|
+
def fit(self, train, validation, context):
|
|
63
|
+
kind = getattr(context, 'base_learner', 'ridge')
|
|
64
|
+
y, cost = train.y / (train.y.std() or 1), train.cost / (train.cost.std() or 1)
|
|
65
|
+
self.arms = []
|
|
66
|
+
for arm in range(1, train.treatments + 1):
|
|
67
|
+
rows = (train.t == arm) | (train.t == 0)
|
|
68
|
+
t = (train.t[rows] == arm).astype(int)
|
|
69
|
+
self.arms.append((r_learner(train.X[rows], t, y[rows], context.model_seed, kind), r_learner(train.X[rows], t, cost[rows], context.model_seed, kind)))
|
|
70
|
+
best = None
|
|
71
|
+
for i, lam in enumerate(LAMBDAS):
|
|
72
|
+
values = []
|
|
73
|
+
for arm, (gain, spend) in enumerate(self.arms, 1):
|
|
74
|
+
rows = (validation.t == arm) | (validation.t == 0)
|
|
75
|
+
X = validation.X[rows]
|
|
76
|
+
score = np.ravel(gain.const_marginal_effect(X)) - lam * np.ravel(spend.const_marginal_effect(X))
|
|
77
|
+
values.append(aucc(score, validation.t[rows], validation.y[rows], validation.cost[rows]))
|
|
78
|
+
values = [v for v in values if v is not None]
|
|
79
|
+
val = float(np.mean(values)) if values else None
|
|
80
|
+
context.report(step=i + 1, total=len(LAMBDAS), lam=lam, val=val if val is not None else 0)
|
|
81
|
+
if best is None or (val is not None and (best[0] is None or val > best[0])):
|
|
82
|
+
best = (val, lam)
|
|
83
|
+
self.lam = best[1]
|
|
84
|
+
|
|
85
|
+
def predict(self, X):
|
|
86
|
+
return np.column_stack([np.ravel(gain.const_marginal_effect(X)) - self.lam * np.ravel(spend.const_marginal_effect(X)) for gain, spend in self.arms])
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Y와 T의 잔차를 cross-fitting으로 구하고, 그 관계로 CATE를 추정해요(Double Machine Learning). 비용은 보지 않아요.
|
|
2
|
+
|
|
3
|
+
경량판: 브라우저에는 EconML이 없어서 웹에서는 같은 방법을 scikit-learn으로 직접 구현한 이 파일을 실행해요.
|
|
4
|
+
로컬(upliftlab)에서는 EconML로 쓴 upliftlab/algorithms/dml.py를 실행해요.
|
|
5
|
+
|
|
6
|
+
ỹ = y − m̂(x) t̃ = t − ê(x) τ̂(x) = θ₀ + θ'x θ = argmin Σ (ỹ − t̃·τ(x))²
|
|
7
|
+
m̂와 ê는 3-fold cross-fitting으로 구해요. 모델은 context.base_learner에 따라 Ridge·로지스틱 회귀 또는 Gradient Boosting이에요(scikit-learn).
|
|
8
|
+
R-Learner와 달리 propensity ê(x)도 추정해서, 처치가 공변량에 따라 달라지는 로그에서도 쓸 수 있어요.
|
|
9
|
+
무작위 배정 데이터에서는 ê(x)가 상수에 가까워 R-Learner와 비슷한 결과가 나와요.
|
|
10
|
+
"""
|
|
11
|
+
import numpy as np
|
|
12
|
+
from sklearn.ensemble import HistGradientBoostingClassifier, HistGradientBoostingRegressor
|
|
13
|
+
from sklearn.linear_model import LogisticRegression, Ridge
|
|
14
|
+
from sklearn.model_selection import KFold, cross_val_predict
|
|
15
|
+
from sklearn.pipeline import make_pipeline
|
|
16
|
+
from sklearn.preprocessing import StandardScaler
|
|
17
|
+
|
|
18
|
+
NAME = 'DML'
|
|
19
|
+
FOLDS = 3
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def outcome_model(kind):
|
|
23
|
+
if kind == 'tree':
|
|
24
|
+
return HistGradientBoostingRegressor(max_depth=3, max_iter=100, early_stopping=False, random_state=0)
|
|
25
|
+
return make_pipeline(StandardScaler(), Ridge(alpha=1.0))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def treatment_model(kind):
|
|
29
|
+
if kind == 'tree':
|
|
30
|
+
return HistGradientBoostingClassifier(max_depth=3, max_iter=100, early_stopping=False, random_state=0)
|
|
31
|
+
return make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def design(scaler, X):
|
|
35
|
+
return np.column_stack([np.ones(len(X)), scaler.transform(X)])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Model:
|
|
39
|
+
def fit(self, train, validation, context):
|
|
40
|
+
kind = getattr(context, 'base_learner', 'ridge')
|
|
41
|
+
self.scaler = StandardScaler().fit(train.X)
|
|
42
|
+
self.finals = []
|
|
43
|
+
for arm in range(1, train.treatments + 1):
|
|
44
|
+
rows = (train.t == arm) | (train.t == 0)
|
|
45
|
+
X, y, t = train.X[rows], train.y[rows], (train.t[rows] == arm).astype(int)
|
|
46
|
+
folds = KFold(FOLDS, shuffle=True, random_state=context.model_seed)
|
|
47
|
+
y_res = y - cross_val_predict(outcome_model(kind), X, y, cv=folds)
|
|
48
|
+
t_res = t - np.clip(cross_val_predict(treatment_model(kind), X, t, cv=folds, method='predict_proba')[:, 1], .01, .99)
|
|
49
|
+
self.finals.append(Ridge(alpha=1.0, fit_intercept=False).fit(design(self.scaler, X) * t_res[:, None], y_res))
|
|
50
|
+
|
|
51
|
+
def predict(self, X):
|
|
52
|
+
Z = design(self.scaler, X)
|
|
53
|
+
return np.column_stack([final.predict(Z) for final in self.finals])
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""성과와 비용의 CATE를 R-Learner로 학습하고 τ̂ʳ − λ·τ̂ᶜ 순으로 정렬해요. λ는 validation AUCC가 가장 높은 값으로 정해요.
|
|
2
|
+
|
|
3
|
+
경량판: 브라우저에는 EconML이 없어서 웹에서는 같은 방법을 scikit-learn으로 직접 구현한 이 파일을 실행해요.
|
|
4
|
+
로컬(upliftlab)에서는 EconML로 쓴 upliftlab/algorithms/duality_r_learner.py를 실행해요.
|
|
5
|
+
|
|
6
|
+
score = τ̂ʳ(x) − λ·τ̂ᶜ(x) τ̂ʳ, τ̂ᶜ는 y / σ_y, cost / σ_c의 CATE예요.
|
|
7
|
+
σ_y, σ_c는 train의 표준편차예요. 정규화하므로 성과와 비용의 단위가 바뀌어도 순위가 같아요.
|
|
8
|
+
회귀 모델은 context.base_learner에 따라 Ridge 또는 Gradient Boosting이에요(scikit-learn).
|
|
9
|
+
"""
|
|
10
|
+
import numpy as np
|
|
11
|
+
from sklearn.ensemble import HistGradientBoostingRegressor
|
|
12
|
+
from sklearn.linear_model import Ridge
|
|
13
|
+
from sklearn.model_selection import KFold, cross_val_predict
|
|
14
|
+
from sklearn.pipeline import make_pipeline
|
|
15
|
+
from sklearn.preprocessing import StandardScaler
|
|
16
|
+
|
|
17
|
+
NAME = 'Duality R-Learner'
|
|
18
|
+
LAMBDAS = [0, 0.05, 0.1, 0.2, 0.4, 0.8, 1.6] # 정규화한 성과와 비용 사이의 가중치
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def base_model(kind):
|
|
22
|
+
if kind == 'tree':
|
|
23
|
+
return HistGradientBoostingRegressor(max_depth=3, max_iter=100, early_stopping=False, random_state=0)
|
|
24
|
+
return make_pipeline(StandardScaler(), Ridge(alpha=1.0))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def r_learner(X, t, y, propensity, seed, kind):
|
|
28
|
+
m = cross_val_predict(base_model(kind), X, y, cv=KFold(2, shuffle=True, random_state=seed))
|
|
29
|
+
w = t - propensity
|
|
30
|
+
weight = {'sample_weight': w ** 2} if kind == 'tree' else {'ridge__sample_weight': w ** 2}
|
|
31
|
+
return base_model(kind).fit(X, (y - m) / w, **weight)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def aucc(score, t, y, cost, points=20):
|
|
35
|
+
"""점수 순으로 상위 S를 골랐을 때 (증분 비용, 증분 성과) 곡선 아래 넓이를 0–1로 맞춘 값. 무작위 순서 ≈ 0.5."""
|
|
36
|
+
order = np.argsort(-score, kind='stable')
|
|
37
|
+
cut = np.round(np.arange(1, points + 1) * len(order) / points).astype(int) - 1
|
|
38
|
+
T = (t[order] > 0).astype(float)
|
|
39
|
+
nT, nC = np.cumsum(T)[cut], np.cumsum(1 - T)[cut]
|
|
40
|
+
ok = (nT > 0) & (nC > 0)
|
|
41
|
+
|
|
42
|
+
def lift(v):
|
|
43
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
44
|
+
return np.where(ok, nT * (np.cumsum(v[order] * T)[cut] / nT - np.cumsum(v[order] * (1 - T))[cut] / nC), 0.)
|
|
45
|
+
gain, spend = np.r_[0., lift(y)], np.r_[0., lift(cost)]
|
|
46
|
+
if gain.max() <= 0 or spend.max() <= 0:
|
|
47
|
+
return None
|
|
48
|
+
x, h = spend / spend.max(), gain / gain.max()
|
|
49
|
+
return float(np.sum(np.diff(x) * (h[1:] + h[:-1]) / 2))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Model:
|
|
53
|
+
score_is_effect = False # 점수에 비용이 이미 들어 있어서 효과로 읽지 않아요. Knapsack과 효율 제약 선정에서 이 순서를 그대로 써요.
|
|
54
|
+
|
|
55
|
+
def fit(self, train, validation, context):
|
|
56
|
+
kind = getattr(context, 'base_learner', 'ridge')
|
|
57
|
+
y, cost = train.y / (train.y.std() or 1), train.cost / (train.cost.std() or 1)
|
|
58
|
+
self.arms = []
|
|
59
|
+
for arm in range(1, train.treatments + 1):
|
|
60
|
+
rows = (train.t == arm) | (train.t == 0)
|
|
61
|
+
t = (train.t[rows] == arm).astype(float)
|
|
62
|
+
e = train.propensities[arm - 1]
|
|
63
|
+
self.arms.append((r_learner(train.X[rows], t, y[rows], e, context.model_seed, kind), r_learner(train.X[rows], t, cost[rows], e, context.model_seed, kind)))
|
|
64
|
+
best = None
|
|
65
|
+
for i, lam in enumerate(LAMBDAS):
|
|
66
|
+
values = []
|
|
67
|
+
for arm, (gain, spend) in enumerate(self.arms, 1):
|
|
68
|
+
rows = (validation.t == arm) | (validation.t == 0)
|
|
69
|
+
values.append(aucc(gain.predict(validation.X[rows]) - lam * spend.predict(validation.X[rows]), validation.t[rows], validation.y[rows], validation.cost[rows]))
|
|
70
|
+
values = [v for v in values if v is not None]
|
|
71
|
+
val = float(np.mean(values)) if values else None
|
|
72
|
+
context.report(step=i + 1, total=len(LAMBDAS), lam=lam, val=val if val is not None else 0)
|
|
73
|
+
if best is None or (val is not None and (best[0] is None or val > best[0])):
|
|
74
|
+
best = (val, lam)
|
|
75
|
+
self.lam = best[1]
|
|
76
|
+
|
|
77
|
+
def predict(self, X):
|
|
78
|
+
return np.column_stack([gain.predict(X) - self.lam * spend.predict(X) for gain, spend in self.arms])
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""결과와 처치의 잔차로 CATE를 학습해요(R-Learner). 비용은 보지 않아요.
|
|
2
|
+
|
|
3
|
+
경량판: 브라우저에는 EconML이 없어서 웹에서는 같은 방법을 scikit-learn으로 직접 구현한 이 파일을 실행해요.
|
|
4
|
+
로컬(upliftlab)에서는 EconML로 쓴 upliftlab/algorithms/r_learner.py를 실행해요.
|
|
5
|
+
|
|
6
|
+
y − m(x) = (t − e)·τ(x) + ε
|
|
7
|
+
m(x)는 2-fold cross-fitting으로 구하고, e는 propensity(상수)예요. τ(x)는 목표 (y − m) / (t − e), 가중치 (t − e)²인 회귀로 풀어요.
|
|
8
|
+
회귀 모델은 context.base_learner에 따라 Ridge 또는 Gradient Boosting이에요(scikit-learn).
|
|
9
|
+
"""
|
|
10
|
+
import numpy as np
|
|
11
|
+
from sklearn.ensemble import HistGradientBoostingRegressor
|
|
12
|
+
from sklearn.linear_model import Ridge
|
|
13
|
+
from sklearn.model_selection import KFold, cross_val_predict
|
|
14
|
+
from sklearn.pipeline import make_pipeline
|
|
15
|
+
from sklearn.preprocessing import StandardScaler
|
|
16
|
+
|
|
17
|
+
NAME = 'R-Learner'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def base_model(kind):
|
|
21
|
+
if kind == 'tree':
|
|
22
|
+
return HistGradientBoostingRegressor(max_depth=3, max_iter=100, early_stopping=False, random_state=0)
|
|
23
|
+
return make_pipeline(StandardScaler(), Ridge(alpha=1.0))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def r_learner(X, t, y, propensity, seed, kind):
|
|
27
|
+
"""τ(x)를 예측하는 모델을 반환해요."""
|
|
28
|
+
m = cross_val_predict(base_model(kind), X, y, cv=KFold(2, shuffle=True, random_state=seed))
|
|
29
|
+
w = t - propensity
|
|
30
|
+
weight = {'sample_weight': w ** 2} if kind == 'tree' else {'ridge__sample_weight': w ** 2}
|
|
31
|
+
return base_model(kind).fit(X, (y - m) / w, **weight)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Model:
|
|
35
|
+
def fit(self, train, validation, context):
|
|
36
|
+
kind = getattr(context, 'base_learner', 'ridge')
|
|
37
|
+
self.taus = []
|
|
38
|
+
for arm in range(1, train.treatments + 1):
|
|
39
|
+
rows = (train.t == arm) | (train.t == 0)
|
|
40
|
+
self.taus.append(r_learner(train.X[rows], (train.t[rows] == arm).astype(float), train.y[rows], train.propensities[arm - 1], context.model_seed, kind))
|
|
41
|
+
|
|
42
|
+
def predict(self, X):
|
|
43
|
+
return np.column_stack([tau.predict(X) for tau in self.taus])
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""처치군과 대조군에 결과 모델을 따로 학습하고, 두 예측의 차이를 CATE로 써요(T-Learner). 비용은 보지 않아요.
|
|
2
|
+
|
|
3
|
+
경량판: 브라우저에는 EconML이 없어서 웹에서는 같은 방법을 scikit-learn으로 직접 구현한 이 파일을 실행해요.
|
|
4
|
+
로컬(upliftlab)에서는 EconML로 쓴 upliftlab/algorithms/t_learner.py를 실행해요.
|
|
5
|
+
|
|
6
|
+
τ̂ₖ(x) = μ̂ₖ(x) − μ̂₀(x) 처치가 여러 종류면 처치마다 계산하고, 점수가 가장 큰 처치를 골라요.
|
|
7
|
+
μ̂는 context.base_learner에 따라 Ridge 또는 Gradient Boosting이에요(scikit-learn).
|
|
8
|
+
"""
|
|
9
|
+
import numpy as np
|
|
10
|
+
from sklearn.ensemble import HistGradientBoostingRegressor
|
|
11
|
+
from sklearn.linear_model import Ridge
|
|
12
|
+
from sklearn.pipeline import make_pipeline
|
|
13
|
+
from sklearn.preprocessing import StandardScaler
|
|
14
|
+
|
|
15
|
+
NAME = 'T-Learner'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def base_model(kind):
|
|
19
|
+
if kind == 'tree':
|
|
20
|
+
return HistGradientBoostingRegressor(max_depth=3, max_iter=100, early_stopping=False, random_state=0)
|
|
21
|
+
return make_pipeline(StandardScaler(), Ridge(alpha=1.0))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Model:
|
|
25
|
+
def fit(self, train, validation, context):
|
|
26
|
+
kind = getattr(context, 'base_learner', 'ridge')
|
|
27
|
+
control = train.t == 0
|
|
28
|
+
self.mu0 = base_model(kind).fit(train.X[control], train.y[control])
|
|
29
|
+
self.mu = [base_model(kind).fit(train.X[train.t == arm], train.y[train.t == arm]) for arm in range(1, train.treatments + 1)]
|
|
30
|
+
|
|
31
|
+
def predict(self, X):
|
|
32
|
+
base = self.mu0.predict(X)
|
|
33
|
+
return np.column_stack([mu.predict(X) - base for mu in self.mu])
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""처치군과 대조군의 결과 모델로 사용자별 처치 효과를 대체(impute)하고, 두 효과 모델을 propensity로 가중 평균해요(X-Learner). 비용은 보지 않아요.
|
|
2
|
+
|
|
3
|
+
경량판: 브라우저에는 EconML이 없어서 웹에서는 같은 방법을 scikit-learn으로 직접 구현한 이 파일을 실행해요.
|
|
4
|
+
로컬(upliftlab)에서는 EconML로 쓴 upliftlab/algorithms/x_learner.py를 실행해요.
|
|
5
|
+
|
|
6
|
+
Dᵀ = y − μ̂₀(x) (처치군) Dᶜ = μ̂₁(x) − y (대조군)
|
|
7
|
+
τ̂(x) = e·τ̂ᶜ(x) + (1 − e)·τ̂ᵀ(x)
|
|
8
|
+
μ̂와 τ̂는 context.base_learner에 따라 Ridge 또는 Gradient Boosting이고(scikit-learn), e는 propensity예요.
|
|
9
|
+
Ridge에서는 T-Learner와 거의 같은 점수가 나와요. 두 방법의 차이는 비선형 모델과 처치 비율이 불균형할 때 커져요.
|
|
10
|
+
"""
|
|
11
|
+
import numpy as np
|
|
12
|
+
from sklearn.ensemble import HistGradientBoostingRegressor
|
|
13
|
+
from sklearn.linear_model import Ridge
|
|
14
|
+
from sklearn.pipeline import make_pipeline
|
|
15
|
+
from sklearn.preprocessing import StandardScaler
|
|
16
|
+
|
|
17
|
+
NAME = 'X-Learner'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def base_model(kind):
|
|
21
|
+
if kind == 'tree':
|
|
22
|
+
return HistGradientBoostingRegressor(max_depth=3, max_iter=100, early_stopping=False, random_state=0)
|
|
23
|
+
return make_pipeline(StandardScaler(), Ridge(alpha=1.0))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Model:
|
|
27
|
+
def fit(self, train, validation, context):
|
|
28
|
+
kind = getattr(context, 'base_learner', 'ridge')
|
|
29
|
+
control = train.t == 0
|
|
30
|
+
X0, y0 = train.X[control], train.y[control]
|
|
31
|
+
mu0 = base_model(kind).fit(X0, y0)
|
|
32
|
+
self.arms = []
|
|
33
|
+
for arm in range(1, train.treatments + 1):
|
|
34
|
+
treated = train.t == arm
|
|
35
|
+
X1, y1 = train.X[treated], train.y[treated]
|
|
36
|
+
mu1 = base_model(kind).fit(X1, y1)
|
|
37
|
+
tau1 = base_model(kind).fit(X1, y1 - mu0.predict(X1))
|
|
38
|
+
tau0 = base_model(kind).fit(X0, mu1.predict(X0) - y0)
|
|
39
|
+
self.arms.append((tau1, tau0, float(train.propensities[arm - 1])))
|
|
40
|
+
|
|
41
|
+
def predict(self, X):
|
|
42
|
+
return np.column_stack([e * tau0.predict(X) + (1 - e) * tau1.predict(X) for tau1, tau0, e in self.arms])
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""결과와 처치의 잔차로 CATE를 학습해요(R-Learner). 비용은 보지 않아요.
|
|
2
|
+
|
|
3
|
+
EconML의 NonParamDML이에요. y − m̂(x) = (t − ê(x))·τ(x) + ε에서 m̂, ê를 2-fold cross-fitting으로 구하고,
|
|
4
|
+
τ(x)는 목표 (y − m̂) / (t − ê), 가중치 (t − ê)²인 회귀로 풀어요. 처치가 여러 종류면 처치마다 대조군과 따로 학습해요.
|
|
5
|
+
"""
|
|
6
|
+
import numpy as np
|
|
7
|
+
from sklearn.ensemble import HistGradientBoostingClassifier, HistGradientBoostingRegressor
|
|
8
|
+
from sklearn.linear_model import LogisticRegression, Ridge
|
|
9
|
+
from sklearn.pipeline import make_pipeline
|
|
10
|
+
from sklearn.preprocessing import StandardScaler
|
|
11
|
+
from econml.dml import NonParamDML
|
|
12
|
+
|
|
13
|
+
NAME = 'R-Learner'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def regressor(kind):
|
|
17
|
+
if kind == 'tree':
|
|
18
|
+
return HistGradientBoostingRegressor(max_depth=3, max_iter=100, early_stopping=False, random_state=0)
|
|
19
|
+
return make_pipeline(StandardScaler(), Ridge(alpha=1.0))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def classifier(kind):
|
|
23
|
+
if kind == 'tree':
|
|
24
|
+
return HistGradientBoostingClassifier(max_depth=3, max_iter=100, early_stopping=False, random_state=0)
|
|
25
|
+
return make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def final(kind):
|
|
29
|
+
"""마지막 단계 회귀. EconML이 sample_weight를 넘기므로 파이프라인이 아닌 모델을 써요."""
|
|
30
|
+
if kind == 'tree':
|
|
31
|
+
return HistGradientBoostingRegressor(max_depth=3, max_iter=100, early_stopping=False, random_state=0)
|
|
32
|
+
return Ridge(alpha=1.0)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Model:
|
|
36
|
+
def fit(self, train, validation, context):
|
|
37
|
+
kind = getattr(context, 'base_learner', 'ridge')
|
|
38
|
+
self.taus = []
|
|
39
|
+
for arm in range(1, train.treatments + 1):
|
|
40
|
+
rows = (train.t == 0) | (train.t == arm)
|
|
41
|
+
est = NonParamDML(model_y=regressor(kind), model_t=classifier(kind), model_final=final(kind), discrete_treatment=True, cv=2, random_state=context.model_seed)
|
|
42
|
+
self.taus.append(est.fit(train.y[rows], (train.t[rows] == arm).astype(int), X=train.X[rows]))
|
|
43
|
+
|
|
44
|
+
def predict(self, X):
|
|
45
|
+
return np.column_stack([np.ravel(est.const_marginal_effect(X)) for est in self.taus])
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""무작위 점수로 정렬해요. 비교 기준선(baseline)이고, Cost Curve는 대각선에 가까워요."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
NAME = 'Random'
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Model:
|
|
8
|
+
score_is_effect = False # 점수가 무작위라서 효과로 읽지 않아요. Knapsack과 효율 제약 선정에서 이 순서를 그대로 써요.
|
|
9
|
+
|
|
10
|
+
def fit(self, train, validation, context):
|
|
11
|
+
self.rng = np.random.default_rng(context.seed)
|
|
12
|
+
self.treatments = train.treatments
|
|
13
|
+
|
|
14
|
+
def predict(self, X):
|
|
15
|
+
return self.rng.random((len(X), self.treatments))
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""처치군과 대조군에 결과 모델을 따로 학습하고, 두 예측의 차이를 CATE로 써요(T-Learner). 비용은 보지 않아요.
|
|
2
|
+
|
|
3
|
+
EconML의 TLearner예요. τ̂ₖ(x) = μ̂ₖ(x) − μ̂₀(x)이고, 처치가 여러 종류면 처치마다 계산해 점수가 가장 큰 처치를 골라요.
|
|
4
|
+
μ̂는 context.base_learner에 따라 Ridge 또는 Gradient Boosting이에요.
|
|
5
|
+
"""
|
|
6
|
+
import numpy as np
|
|
7
|
+
from sklearn.ensemble import HistGradientBoostingRegressor
|
|
8
|
+
from sklearn.linear_model import Ridge
|
|
9
|
+
from sklearn.pipeline import make_pipeline
|
|
10
|
+
from sklearn.preprocessing import StandardScaler
|
|
11
|
+
from econml.metalearners import TLearner
|
|
12
|
+
|
|
13
|
+
NAME = 'T-Learner'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def regressor(kind):
|
|
17
|
+
if kind == 'tree':
|
|
18
|
+
return HistGradientBoostingRegressor(max_depth=3, max_iter=100, early_stopping=False, random_state=0)
|
|
19
|
+
return make_pipeline(StandardScaler(), Ridge(alpha=1.0))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Model:
|
|
23
|
+
def fit(self, train, validation, context):
|
|
24
|
+
kind = getattr(context, 'base_learner', 'ridge')
|
|
25
|
+
self.est = TLearner(models=regressor(kind)).fit(train.y, train.t, X=train.X)
|
|
26
|
+
|
|
27
|
+
def predict(self, X):
|
|
28
|
+
return np.asarray(self.est.const_marginal_effect(X)).reshape(len(X), -1)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""처치군과 대조군의 결과 모델로 사용자별 처치 효과를 대체(impute)하고, 두 효과 모델을 propensity로 가중 평균해요(X-Learner). 비용은 보지 않아요.
|
|
2
|
+
|
|
3
|
+
EconML의 XLearner예요. Dᵀ = y − μ̂₀(x), Dᶜ = μ̂₁(x) − y로 효과 모델 τ̂ᵀ, τ̂ᶜ를 학습하고 τ̂(x) = e(x)·τ̂ᶜ(x) + (1 − e(x))·τ̂ᵀ(x)로 합쳐요.
|
|
4
|
+
모델은 context.base_learner에 따라 Ridge·로지스틱 회귀 또는 Gradient Boosting이에요.
|
|
5
|
+
"""
|
|
6
|
+
import numpy as np
|
|
7
|
+
from sklearn.ensemble import HistGradientBoostingClassifier, HistGradientBoostingRegressor
|
|
8
|
+
from sklearn.linear_model import LogisticRegression, Ridge
|
|
9
|
+
from sklearn.pipeline import make_pipeline
|
|
10
|
+
from sklearn.preprocessing import StandardScaler
|
|
11
|
+
from econml.metalearners import XLearner
|
|
12
|
+
|
|
13
|
+
NAME = 'X-Learner'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def regressor(kind):
|
|
17
|
+
if kind == 'tree':
|
|
18
|
+
return HistGradientBoostingRegressor(max_depth=3, max_iter=100, early_stopping=False, random_state=0)
|
|
19
|
+
return make_pipeline(StandardScaler(), Ridge(alpha=1.0))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def classifier(kind):
|
|
23
|
+
if kind == 'tree':
|
|
24
|
+
return HistGradientBoostingClassifier(max_depth=3, max_iter=100, early_stopping=False, random_state=0)
|
|
25
|
+
return make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Model:
|
|
29
|
+
def fit(self, train, validation, context):
|
|
30
|
+
kind = getattr(context, 'base_learner', 'ridge')
|
|
31
|
+
self.est = XLearner(models=regressor(kind), propensity_model=classifier(kind)).fit(train.y, train.t, X=train.X)
|
|
32
|
+
|
|
33
|
+
def predict(self, X):
|
|
34
|
+
return np.asarray(self.est.const_marginal_effect(X)).reshape(len(X), -1)
|