usmar commited on
Commit
9000bbd
·
verified ·
1 Parent(s): 7690750

Publish the undertow interface

Browse files
Files changed (5) hide show
  1. README.md +27 -5
  2. data.json +1 -0
  3. index.html +302 -17
  4. models.json +0 -0
  5. undertow.js +374 -0
README.md CHANGED
@@ -1,10 +1,32 @@
1
  ---
2
- title: Undertow
3
- emoji: 👀
4
- colorFrom: green
5
- colorTo: indigo
6
  sdk: static
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: undertow
3
+ emoji: 🌊
4
+ colorFrom: blue
5
+ colorTo: gray
6
  sdk: static
7
  pinned: false
8
+ license: mit
9
+ short_description: Four sentiment corpora, every model tested on all four
10
  ---
11
 
12
+ # undertow
13
+
14
+ A sentiment classifier reports one number: accuracy on a test set drawn from the same
15
+ corpus it trained on. **undertow** is about the other twelve.
16
+
17
+ Four corpora — IMDB, SST-2, Yelp, Amazon — two classifiers, and every model evaluated on
18
+ every domain. A bag of words and a linear probe on frozen DistilBERT features are nearly
19
+ tied on the test set everyone reports (86.0% vs 87.1%). On the three domains nobody
20
+ reports, the probe wins by 3.5 points and carries 29% less domain-shift penalty. Picking
21
+ the method on in-domain accuracy gets it wrong on two of the four corpora.
22
+
23
+ This page runs the TF-IDF arm live in your browser: type a review and the four models,
24
+ identical in every way except which 6,000 reviews they read, each give you a verdict.
25
+ Nothing is sent anywhere and no answer is precomputed — the JavaScript is a tested port of
26
+ the Python original, held to it at 1e-9.
27
+
28
+ Corpora: [IMDB](https://ai.stanford.edu/~amaas/data/sentiment/) (Maas et al., 2011),
29
+ [SST-2](https://gluebenchmark.com/tasks) (Socher et al., 2013), Yelp and Amazon polarity
30
+ (Zhang et al., 2015).
31
+
32
+ Method, controls and the full write-up: <https://github.com/UsmarHaider/undertow>
data.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"domains":[{"key":"imdb","label":"IMDB","topic":"movies","length":"long","blurb":"Full-length movie reviews from IMDB (Maas et al., 2011)."},{"key":"sst2","label":"SST-2","topic":"movies","length":"short","blurb":"Single-sentence movie-review snippets from the Stanford Sentiment Treebank."},{"key":"yelp","label":"Yelp","topic":"commerce","length":"long","blurb":"Business and restaurant reviews from the Yelp polarity set (Zhang et al., 2015)."},{"key":"amazon","label":"Amazon","topic":"commerce","length":"long","blurb":"Product reviews from the Amazon polarity set (Zhang et al., 2015)."}],"methods":[{"key":"tfidf","label":"TF-IDF + LR","blurb":"No pretraining at all: word/char n-grams into logistic regression. The control."},{"key":"probe","label":"Linear probe","blurb":"Encoder frozen at its pretrained weights; only a linear head is trained."},{"key":"ft","label":"Fine-tune","blurb":"Everything trained end to end from a randomly initialised head."},{"key":"lpft","label":"LP-FT","blurb":"Probe first, then fine-tune everything starting from that head (Kumar et al., 2022)."}],"grid":[{"method":"tfidf","train":"imdb","eval":"imdb","accuracy":0.89,"auroc":0.95548,"oracle_accuracy":0.8925,"ece":0.04523,"positive_rate":0.5145,"n":4000},{"method":"tfidf","train":"imdb","eval":"sst2","accuracy":0.75584,"auroc":0.83595,"oracle_accuracy":0.76168,"ece":0.05019,"positive_rate":0.56425,"n":856},{"method":"tfidf","train":"imdb","eval":"yelp","accuracy":0.83225,"auroc":0.9174,"oracle_accuracy":0.83475,"ece":0.04099,"positive_rate":0.50475,"n":4000},{"method":"tfidf","train":"imdb","eval":"amazon","accuracy":0.815,"auroc":0.90786,"oracle_accuracy":0.8245,"ece":0.03108,"positive_rate":0.541,"n":4000},{"method":"probe","train":"imdb","eval":"imdb","accuracy":0.85325,"auroc":0.92651,"oracle_accuracy":0.854,"ece":0.01842,"positive_rate":0.49425,"n":4000},{"method":"probe","train":"imdb","eval":"sst2","accuracy":0.81659,"auroc":0.89228,"oracle_accuracy":0.82944,"ece":0.08696,"positive_rate":0.54556,"n":856},{"method":"probe","train":"imdb","eval":"yelp","accuracy":0.776,"auroc":0.92162,"oracle_accuracy":0.848,"ece":0.06786,"positive_rate":0.6575,"n":4000},{"method":"probe","train":"imdb","eval":"amazon","accuracy":0.8245,"auroc":0.92313,"oracle_accuracy":0.8445,"ece":0.03808,"positive_rate":0.572,"n":4000},{"method":"tfidf","train":"sst2","eval":"imdb","accuracy":0.74075,"auroc":0.87223,"oracle_accuracy":0.797,"ece":0.03908,"positive_rate":0.31825,"n":4000},{"method":"tfidf","train":"sst2","eval":"sst2","accuracy":0.75935,"auroc":0.85429,"oracle_accuracy":0.77453,"ece":0.04403,"positive_rate":0.51402,"n":856},{"method":"tfidf","train":"sst2","eval":"yelp","accuracy":0.744,"auroc":0.85866,"oracle_accuracy":0.77525,"ece":0.02788,"positive_rate":0.3205,"n":4000},{"method":"tfidf","train":"sst2","eval":"amazon","accuracy":0.7315,"auroc":0.84198,"oracle_accuracy":0.766,"ece":0.03088,"positive_rate":0.344,"n":4000},{"method":"probe","train":"sst2","eval":"imdb","accuracy":0.803,"auroc":0.89812,"oracle_accuracy":0.81725,"ece":0.04895,"positive_rate":0.3975,"n":4000},{"method":"probe","train":"sst2","eval":"sst2","accuracy":0.84112,"auroc":0.91053,"oracle_accuracy":0.84112,"ece":0.02411,"positive_rate":0.48364,"n":856},{"method":"probe","train":"sst2","eval":"yelp","accuracy":0.85325,"auroc":0.94071,"oracle_accuracy":0.868,"ece":0.05162,"positive_rate":0.42675,"n":4000},{"method":"probe","train":"sst2","eval":"amazon","accuracy":0.8165,"auroc":0.91471,"oracle_accuracy":0.83075,"ece":0.01821,"positive_rate":0.3815,"n":4000},{"method":"tfidf","train":"yelp","eval":"imdb","accuracy":0.77325,"auroc":0.88132,"oracle_accuracy":0.79675,"ece":0.05488,"positive_rate":0.38825,"n":4000},{"method":"tfidf","train":"yelp","eval":"sst2","accuracy":0.68575,"auroc":0.75413,"oracle_accuracy":0.69393,"ece":0.03664,"positive_rate":0.58294,"n":856},{"method":"tfidf","train":"yelp","eval":"yelp","accuracy":0.9145,"auroc":0.97352,"oracle_accuracy":0.9165,"ece":0.0887,"positive_rate":0.496,"n":4000},{"method":"tfidf","train":"yelp","eval":"amazon","accuracy":0.8345,"auroc":0.91429,"oracle_accuracy":0.83775,"ece":0.0922,"positive_rate":0.4695,"n":4000},{"method":"probe","train":"yelp","eval":"imdb","accuracy":0.775,"auroc":0.85999,"oracle_accuracy":0.779,"ece":0.0705,"positive_rate":0.4375,"n":4000},{"method":"probe","train":"yelp","eval":"sst2","accuracy":0.76519,"auroc":0.84705,"oracle_accuracy":0.77103,"ece":0.12793,"positive_rate":0.51986,"n":856},{"method":"probe","train":"yelp","eval":"yelp","accuracy":0.90625,"auroc":0.9663,"oracle_accuracy":0.90625,"ece":0.01732,"positive_rate":0.48925,"n":4000},{"method":"probe","train":"yelp","eval":"amazon","accuracy":0.84625,"auroc":0.93124,"oracle_accuracy":0.8565,"ece":0.03564,"positive_rate":0.52975,"n":4000},{"method":"tfidf","train":"amazon","eval":"imdb","accuracy":0.8315,"auroc":0.91761,"oracle_accuracy":0.836,"ece":0.08032,"positive_rate":0.466,"n":4000},{"method":"tfidf","train":"amazon","eval":"sst2","accuracy":0.71612,"auroc":0.79321,"oracle_accuracy":0.72196,"ece":0.04625,"positive_rate":0.57126,"n":856},{"method":"tfidf","train":"amazon","eval":"yelp","accuracy":0.865,"auroc":0.94176,"oracle_accuracy":0.86775,"ece":0.09825,"positive_rate":0.495,"n":4000},{"method":"tfidf","train":"amazon","eval":"amazon","accuracy":0.87675,"auroc":0.94913,"oracle_accuracy":0.88,"ece":0.09483,"positive_rate":0.49475,"n":4000},{"method":"probe","train":"amazon","eval":"imdb","accuracy":0.82,"auroc":0.90316,"oracle_accuracy":0.82275,"ece":0.01495,"positive_rate":0.47,"n":4000},{"method":"probe","train":"amazon","eval":"sst2","accuracy":0.79089,"auroc":0.88588,"oracle_accuracy":0.81659,"ece":0.08982,"positive_rate":0.55257,"n":856},{"method":"probe","train":"amazon","eval":"yelp","accuracy":0.86275,"auroc":0.94988,"oracle_accuracy":0.87675,"ece":0.01488,"positive_rate":0.55825,"n":4000},{"method":"probe","train":"amazon","eval":"amazon","accuracy":0.88425,"auroc":0.95476,"oracle_accuracy":0.88725,"ece":0.01302,"positive_rate":0.48875,"n":4000}],"report":{"methods":[{"method":"tfidf","id_accuracy":0.8601489485981308,"ood_accuracy":0.7771216900311527,"ood_gap":0.08302725856697812,"id_auroc":0.9331054482869683,"ood_auroc":0.8697007361605453,"ood_oracle_accuracy":0.7927766744548287,"id_ece":0.06819728512338892,"ood_ece":0.05238538153535782,"total_drop":0.08302725856697812,"ranking_drop":0.06737227414330216,"placement_drop":0.015654984423675966},{"method":"probe","id_accuracy":0.8712178738317757,"ood_accuracy":0.8124927959501558,"ood_gap":0.05872507788161996,"id_auroc":0.9395254573161411,"ood_auroc":0.9056478458908347,"ood_oracle_accuracy":0.830046339563863,"id_ece":0.018215950827522174,"ood_ece":0.0554498978555863,"total_drop":0.05872507788161996,"ranking_drop":0.04117153426791276,"placement_drop":0.0175535436137072}],"headline":{"primary_method":"probe","tfidf_id_accuracy":0.8601489485981308,"tfidf_ood_accuracy":0.7771216900311527,"tfidf_ood_gap":0.08302725856697812,"tfidf_ranking_drop":0.06737227414330216,"tfidf_placement_drop":0.015654984423675966,"tfidf_placement_share":0.1885523464688056,"probe_id_accuracy":0.8712178738317757,"probe_ood_accuracy":0.8124927959501558,"probe_ood_gap":0.05872507788161996,"probe_ranking_drop":0.04117153426791276,"probe_placement_drop":0.0175535436137072,"probe_placement_share":0.2989105208015601},"shift_axes":[{"method":"probe","shift":"topic-different/length-different","accuracy":0.806456191588785,"n":4},{"method":"probe","shift":"topic-different/length-same","accuracy":0.798875,"n":4},{"method":"probe","shift":"topic-same/length-different","accuracy":0.8097943925233645,"n":2},{"method":"probe","shift":"topic-same/length-same","accuracy":0.8545,"n":2},{"method":"tfidf","shift":"topic-different/length-different","accuracy":0.7193422897196262,"n":4},{"method":"tfidf","shift":"topic-different/length-same","accuracy":0.813,"n":4},{"method":"tfidf","shift":"topic-same/length-different","accuracy":0.7482955607476636,"n":2},{"method":"tfidf","shift":"topic-same/length-same","accuracy":0.84975,"n":2}],"mechanism":[{"method":"probe","scope":"id","cka_vs_pretrained":1.0,"posthoc_probe_accuracy":null,"pretrained_probe_accuracy":null,"probe_accuracy_delta":0.0,"n":4},{"method":"probe","scope":"ood","cka_vs_pretrained":1.0,"posthoc_probe_accuracy":null,"pretrained_probe_accuracy":null,"probe_accuracy_delta":0.0,"n":12}],"selection_regret":{"rows":[{"train_domain":"imdb","chosen_on_id":"tfidf","chosen_id_accuracy":0.89,"chosen_ood_accuracy":0.8010303738317757,"best_ood_method":"probe","best_ood_accuracy":0.8056962616822431,"regret":0.004665887850467398,"agrees":false},{"train_domain":"sst2","chosen_on_id":"probe","chosen_id_accuracy":0.8411214953271028,"chosen_ood_accuracy":0.82425,"best_ood_method":"probe","best_ood_accuracy":0.82425,"regret":0.0,"agrees":true},{"train_domain":"yelp","chosen_on_id":"tfidf","chosen_id_accuracy":0.9145,"chosen_ood_accuracy":0.7644992211838005,"best_ood_method":"probe","best_ood_accuracy":0.7954789719626169,"regret":0.030979750778816317,"agrees":false},{"train_domain":"amazon","chosen_on_id":"probe","chosen_id_accuracy":0.88425,"chosen_ood_accuracy":0.8245459501557632,"best_ood_method":"probe","best_ood_accuracy":0.8245459501557632,"regret":0.0,"agrees":true}],"disagreements":2,"mean_regret":0.008911409657320929,"max_regret":0.030979750778816317},"checkpoint_regret":{"rows":[],"mean_regret":null,"runs_where_earlier_checkpoint_was_better":0,"runs":0},"ft_vs_lpft":{"baseline":"ft","challenger":"lpft","id_mean_delta":null,"ood_mean_delta":null,"ood_cells":0,"ood_wins":0,"cells":[]},"probe_vs_ft":{"baseline":"probe","challenger":"ft","id_mean_delta":null,"ood_mean_delta":null,"ood_cells":0,"ood_wins":0,"cells":[]},"tfidf_vs_ft":{"baseline":"tfidf","challenger":"ft","id_mean_delta":null,"ood_mean_delta":null,"ood_cells":0,"ood_wins":0,"cells":[]}},"examples":[{"label":"Restaurant","text":"The service was slow but the pasta was worth the wait."},{"label":"Product","text":"Battery lasted about two days on a charge and the case feels solid after a month of being thrown in a bag. Setup took under five minutes."},{"label":"Film","text":"The premise is wasted on a script that explains every feeling out loud, and even the cast look bored by the third act. A long two hours."},{"label":"One-liner","text":"a quietly devastating performance that lingers long after the credits"}]}
index.html CHANGED
@@ -1,19 +1,304 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>undertow what fine-tuning costs you off-distribution</title>
7
+ <meta name="description" content="Four sentiment models, four corpora, one architecture. Type a review and watch them disagree.">
8
+ <style>
9
+ :root {
10
+ color-scheme: light dark;
11
+ --blue: #2a78d6;
12
+ --orange: #eb6834;
13
+ --green: #2f9e6f;
14
+ --slate: #4a5568;
15
+ --grey: #9aa5b1;
16
+
17
+ --bg: #fbfcfd;
18
+ --panel: #ffffff;
19
+ --ink: #17212b;
20
+ --ink-soft: #55636f;
21
+ --ink-faint: #8593a0;
22
+ --line: #e2e7ec;
23
+ --shadow: 0 1px 2px rgba(16, 32, 48, .06), 0 8px 24px rgba(16, 32, 48, .05);
24
+ --heat: 42, 120, 214;
25
+ }
26
+ @media (prefers-color-scheme: dark) {
27
+ :root {
28
+ --bg: #10161c;
29
+ --panel: #171f27;
30
+ --ink: #e7edf3;
31
+ --ink-soft: #a3b1bf;
32
+ --ink-faint: #6f8090;
33
+ --line: #26313c;
34
+ --shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 10px 30px rgba(0, 0, 0, .25);
35
+ }
36
+ }
37
+ * { box-sizing: border-box; }
38
+ html { -webkit-text-size-adjust: 100%; }
39
+ body {
40
+ margin: 0;
41
+ background: var(--bg);
42
+ color: var(--ink);
43
+ font: 15px/1.6 ui-sans-serif, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
44
+ font-variant-numeric: tabular-nums;
45
+ }
46
+ .wrap { max-width: 1080px; margin: 0 auto; padding: 0 20px 72px; }
47
+
48
+ /* ------------------------------------------------------------------------- header */
49
+ header.top { padding: 56px 0 28px; border-bottom: 1px solid var(--line); margin-bottom: 34px; }
50
+ .eyebrow {
51
+ font-size: 12px; letter-spacing: .14em; text-transform: uppercase;
52
+ color: var(--ink-faint); margin: 0 0 10px;
53
+ }
54
+ h1 { font-size: clamp(34px, 6vw, 50px); line-height: 1.04; margin: 0 0 14px; letter-spacing: -.022em; }
55
+ h1 .dim { color: var(--ink-faint); font-weight: 400; }
56
+ .lede { font-size: 17px; color: var(--ink-soft); max-width: 62ch; margin: 0 0 18px; }
57
+ .lede strong { color: var(--ink); font-weight: 600; }
58
+ .top-links { display: flex; flex-wrap: wrap; gap: 8px; }
59
+ .top-links a {
60
+ font-size: 13px; text-decoration: none; color: var(--ink-soft);
61
+ border: 1px solid var(--line); border-radius: 999px; padding: 5px 13px; background: var(--panel);
62
+ }
63
+ .top-links a:hover { color: var(--blue); border-color: var(--blue); }
64
+
65
+ /* ------------------------------------------------------------------------ sections */
66
+ section { margin: 0 0 52px; }
67
+ h2 { font-size: 21px; margin: 0 0 6px; letter-spacing: -.01em; }
68
+ .sub { color: var(--ink-soft); margin: 0 0 20px; max-width: 68ch; }
69
+ .panel {
70
+ background: var(--panel); border: 1px solid var(--line);
71
+ border-radius: 14px; padding: 22px; box-shadow: var(--shadow);
72
+ }
73
+
74
+ /* -------------------------------------------------------------------- the scorer */
75
+ #text {
76
+ width: 100%; min-height: 116px; resize: vertical; padding: 14px 15px;
77
+ border: 1px solid var(--line); border-radius: 10px; background: var(--bg);
78
+ color: var(--ink); font: inherit; line-height: 1.55;
79
+ }
80
+ #text:focus { outline: 2px solid var(--blue); outline-offset: -1px; border-color: transparent; }
81
+ #examples { display: flex; flex-wrap: wrap; gap: 7px; margin: 12px 0 0; }
82
+ .chip {
83
+ font: inherit; font-size: 13px; cursor: pointer; padding: 5px 12px;
84
+ border-radius: 999px; border: 1px solid var(--line);
85
+ background: var(--bg); color: var(--ink-soft);
86
+ }
87
+ .chip:hover { border-color: var(--blue); color: var(--blue); }
88
+ .chip-ghost { color: var(--ink-faint); }
89
+
90
+ .note { margin: 18px 0 0; padding: 12px 14px; border-radius: 10px; font-size: 14px; line-height: 1.55; }
91
+ .note-idle { background: color-mix(in srgb, var(--grey) 12%, transparent); color: var(--ink-soft); }
92
+ .note-split { background: color-mix(in srgb, var(--orange) 14%, transparent); color: var(--ink); }
93
+ .note-agree { background: color-mix(in srgb, var(--green) 13%, transparent); color: var(--ink); }
94
+
95
+ #verdicts {
96
+ display: grid; gap: 14px; margin-top: 18px;
97
+ grid-template-columns: repeat(auto-fit, minmax(216px, 1fr));
98
+ }
99
+ .verdict {
100
+ border: 1px solid var(--line); border-radius: 12px; padding: 14px 15px 12px;
101
+ background: var(--bg); border-top: 3px solid var(--grey);
102
+ }
103
+ .verdict.is-positive { border-top-color: var(--green); }
104
+ .verdict.is-negative { border-top-color: var(--orange); }
105
+ .verdict-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
106
+ .verdict-domain { font-weight: 650; font-size: 15px; }
107
+ .verdict-meta { font-size: 11px; color: var(--ink-faint); letter-spacing: .03em; }
108
+ .verdict-empty { color: var(--ink-faint); font-size: 13px; margin: 14px 0; }
109
+ .verdict-label { margin: 10px 0 8px; font-size: 17px; font-weight: 650; letter-spacing: -.01em; }
110
+ .is-positive .verdict-label { color: var(--green); }
111
+ .is-negative .verdict-label { color: var(--orange); }
112
+ .verdict-prob { font-size: 12px; font-weight: 450; color: var(--ink-faint); letter-spacing: 0; }
113
+ .meter { position: relative; height: 7px; border-radius: 4px; background: color-mix(in srgb, var(--orange) 26%, transparent); overflow: hidden; }
114
+ .meter-fill { height: 100%; background: var(--green); border-radius: 4px 0 0 4px; }
115
+ /* The decision boundary. Without a visible mark at 0.5 the bar reads as a generic
116
+ progress meter rather than "which side of the threshold did this land on". */
117
+ .meter-mid { position: absolute; left: 50%; top: -3px; bottom: -3px; width: 2px; margin-left: -1px; background: var(--ink); opacity: .55; border-radius: 1px; }
118
+ .terms { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 3px; }
119
+ .term { display: flex; justify-content: space-between; font-size: 12.5px; gap: 10px; }
120
+ .term-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-soft); }
121
+ .term-weight { font-variant-numeric: tabular-nums; }
122
+ .term-pos .term-weight { color: var(--green); }
123
+ .term-neg .term-weight { color: var(--orange); }
124
+ .verdict-foot { margin: 11px 0 0; font-size: 11.5px; color: var(--ink-faint); }
125
+
126
+ /* ------------------------------------------------------------------ transfer matrix */
127
+ #method-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
128
+ .tab {
129
+ font: inherit; font-size: 13.5px; cursor: pointer; padding: 6px 14px;
130
+ border-radius: 8px; border: 1px solid var(--line); background: var(--bg); color: var(--ink-soft);
131
+ }
132
+ .tab:hover { color: var(--ink); }
133
+ .tab.is-active { background: var(--blue); border-color: var(--blue); color: #fff; }
134
+ .matrix-scroll { overflow-x: auto; }
135
+ #matrix {
136
+ display: grid; grid-template-columns: minmax(78px, auto) repeat(4, minmax(74px, 1fr));
137
+ gap: 4px; min-width: 440px;
138
+ }
139
+ .mcell { padding: 11px 8px; border-radius: 7px; text-align: center; font-size: 14px; }
140
+ .mcell-corner { background: none; }
141
+ .mcell-head { font-size: 12px; color: var(--ink-faint); font-weight: 600; letter-spacing: .02em; }
142
+ .mcell-row { text-align: right; padding-right: 12px; display: flex; align-items: center; justify-content: flex-end; }
143
+ .mcell-value { background: rgba(var(--heat), calc(.07 + var(--t) * .82)); color: var(--ink); font-variant-numeric: tabular-nums; }
144
+ .mcell-value.is-dark { color: #fff; }
145
+ .mcell-value.is-diagonal { font-weight: 700; box-shadow: inset 0 0 0 2px var(--ink); }
146
+ .mcell-value.is-diagonal.is-dark { box-shadow: inset 0 0 0 2px #fff; }
147
+ .axis-note { display: flex; justify-content: space-between; font-size: 12px; color: var(--ink-faint); margin-top: 10px; gap: 16px; }
148
+ #matrix-blurb { margin: 14px 0 0; font-size: 13.5px; color: var(--ink-soft); }
149
+
150
+ /* ---------------------------------------------------------------------- tables */
151
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
152
+ th, td { text-align: right; padding: 9px 10px; border-bottom: 1px solid var(--line); }
153
+ thead th { font-size: 12px; color: var(--ink-faint); font-weight: 600; letter-spacing: .02em; }
154
+ tbody th, thead th:first-child { text-align: left; font-weight: 600; }
155
+ tbody tr:last-child th, tbody tr:last-child td { border-bottom: none; }
156
+ .num-gap { color: var(--orange); font-weight: 600; }
157
+ .table-scroll { overflow-x: auto; }
158
+
159
+ /* ----------------------------------------------------------------- decomposition */
160
+ #decomposition { display: grid; gap: 10px; }
161
+ .decomp-row { display: grid; grid-template-columns: 96px 1fr 74px; align-items: center; gap: 12px; }
162
+ .decomp-label { font-size: 13.5px; color: var(--ink-soft); }
163
+ .decomp-bar { display: flex; height: 20px; border-radius: 5px; overflow: hidden; background: color-mix(in srgb, var(--grey) 14%, transparent); }
164
+ .seg { height: 100%; }
165
+ .seg-ranking { background: var(--orange); }
166
+ .seg-placement { background: var(--blue); }
167
+ .decomp-total { font-size: 13px; text-align: right; color: var(--ink-soft); font-variant-numeric: tabular-nums; }
168
+ .legend { display: flex; gap: 18px; flex-wrap: wrap; margin-top: 16px; font-size: 12.5px; color: var(--ink-soft); }
169
+ .legend span { display: inline-flex; align-items: center; gap: 7px; }
170
+ .swatch { width: 11px; height: 11px; border-radius: 3px; }
171
+
172
+ footer {
173
+ border-top: 1px solid var(--line); padding-top: 22px; margin-top: 8px;
174
+ font-size: 13px; color: var(--ink-faint); line-height: 1.7;
175
+ }
176
+ footer a { color: var(--ink-soft); }
177
+ #loading { padding: 60px 0; color: var(--ink-faint); }
178
+ </style>
179
+ </head>
180
+ <body>
181
+ <div class="wrap">
182
+
183
+ <header class="top">
184
+ <p class="eyebrow">Distribution shift · DistilBERT · four sentiment corpora</p>
185
+ <h1>undertow<span class="dim"> — the accuracy that isn't there</span></h1>
186
+ <p class="lede">
187
+ A sentiment classifier reports one number: accuracy on a test set drawn from the corpus
188
+ it trained on. This page is about the other twelve. Four corpora, two classifiers — a bag
189
+ of words, and a linear probe on frozen DistilBERT features — and every model evaluated on
190
+ every domain. Pretraining is worth <strong>+1.1 pp</strong> on the test set everyone
191
+ reports. On the three domains nobody reports it is worth <strong>+3.5 pp</strong>, and it
192
+ cuts the domain-shift penalty by <strong>29%</strong>.
193
+ </p>
194
+ <div class="top-links">
195
+ <a href="https://github.com/UsmarHaider/undertow">Method &amp; code on GitHub</a>
196
+ </div>
197
+ </header>
198
+
199
+ <p id="loading">Loading the models…</p>
200
+
201
+ <main id="app" hidden>
202
+
203
+ <section>
204
+ <h2>Four models, one sentence</h2>
205
+ <p class="sub">
206
+ These are the real fitted TF-IDF models from the run below — one per corpus, identical
207
+ settings, running in your browser with nothing sent anywhere. Same architecture, same
208
+ hyper-parameters, same 6,000 training reviews each. The only thing that differs is
209
+ <em>which</em> 6,000.
210
+ </p>
211
+ <div class="panel">
212
+ <textarea id="text" placeholder="Paste or type a review…" spellcheck="false"></textarea>
213
+ <div id="examples"></div>
214
+ <p id="disagreement" class="note note-idle"></p>
215
+ <div id="verdicts"></div>
216
+ </div>
217
+ </section>
218
+
219
+ <section>
220
+ <h2>The transfer matrix</h2>
221
+ <p class="sub">
222
+ Row is what a model trained on; column is what it was tested on. The boxed diagonal is
223
+ the number that gets published. Everything off it is the number you get in production.
224
+ Hover any cell for its AUROC, its calibration, and how far a moved threshold would take it.
225
+ </p>
226
+ <div class="panel">
227
+ <div id="method-tabs"></div>
228
+ <div class="matrix-scroll"><div id="matrix"></div></div>
229
+ <div class="axis-note"><span>rows: trained on</span><span>columns: evaluated on</span></div>
230
+ <p id="matrix-blurb"></p>
231
+ </div>
232
+ </section>
233
+
234
+ <section>
235
+ <h2>In-domain versus everywhere else</h2>
236
+ <p class="sub">
237
+ Averaged over the four training domains — sixteen cells per method, four on the diagonal
238
+ and twelve off it. <em>Oracle</em> is the accuracy each model would reach out-of-domain if
239
+ someone re-placed its decision threshold and changed nothing else: the ceiling that costs
240
+ no retraining.
241
+ </p>
242
+ <div class="panel table-scroll">
243
+ <table>
244
+ <thead>
245
+ <tr>
246
+ <th>method</th><th>in-domain</th><th>out-of-domain</th><th>drop</th>
247
+ <th>OOD AUROC</th><th>OOD oracle</th><th>OOD ECE</th>
248
+ </tr>
249
+ </thead>
250
+ <tbody id="summary-body"></tbody>
251
+ </table>
252
+ </div>
253
+ </section>
254
+
255
+ <section>
256
+ <h2>What the drop is made of</h2>
257
+ <p class="sub">
258
+ The loss splits cleanly in two. <strong>Ranking</strong> is signal the representation
259
+ genuinely no longer has — no threshold recovers it. <strong>Placement</strong> is signal
260
+ that is still there, sitting on the wrong side of a boundary that was chosen in a
261
+ different domain.
262
+ </p>
263
+ <div class="panel">
264
+ <div id="decomposition"></div>
265
+ <div class="legend">
266
+ <span><i class="swatch" style="background: var(--orange)"></i> ranking — genuinely lost</span>
267
+ <span><i class="swatch" style="background: var(--blue)"></i> placement — a misplaced threshold</span>
268
+ </div>
269
+ </div>
270
+ </section>
271
+
272
+ <section>
273
+ <h2>Which shift hurts</h2>
274
+ <p class="sub">
275
+ The four corpora form a rough 2×2 — movies against commerce, full reviews against
276
+ one-liners — so the twelve out-of-domain cells can be grouped by what kind of distance
277
+ each one actually crosses.
278
+ </p>
279
+ <div class="panel table-scroll">
280
+ <table>
281
+ <thead><tr id="shift-head"></tr></thead>
282
+ <tbody id="shift-body"></tbody>
283
+ </table>
284
+ </div>
285
+ </section>
286
+
287
+ <footer>
288
+ <p>
289
+ Corpora: <a href="https://ai.stanford.edu/~amaas/data/sentiment/">IMDB</a> (Maas et al., 2011),
290
+ <a href="https://gluebenchmark.com/tasks">SST-2</a> (Socher et al., 2013),
291
+ Yelp and Amazon polarity (Zhang et al., 2015). Encoder: distilbert-base-uncased.
292
+ Every number on this page is regenerated by <code>make all</code>.
293
+ </p>
294
+ <p>
295
+ Built by <a href="https://github.com/UsmarHaider">Usmar Haider</a> ·
296
+ <a href="https://github.com/UsmarHaider/undertow">source, method and full write-up</a> · MIT
297
+ </p>
298
+ </footer>
299
+ </main>
300
+
301
+ </div>
302
+ <script src="undertow.js"></script>
303
+ </body>
304
  </html>
models.json ADDED
The diff for this file is too large to render. See raw diff
 
undertow.js ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* undertow -- the browser half.
2
+ *
3
+ * Everything here runs on the visitor's machine. The four TF-IDF models are the real
4
+ * fitted models from `make grid`, shipped as JSON, and this file is a line-for-line port
5
+ * of `undertow/tfidf.py`: same tokeniser, same sublinear TF, same smooth IDF, same L2
6
+ * norm, same linear margin. A test in the suite runs this file under Node against the
7
+ * Python implementation on the same strings and requires agreement to 1e-9, so the four
8
+ * verdicts you see are the four verdicts the paper's tables were computed from.
9
+ *
10
+ * It is a port rather than an API call on purpose: the point of the page is that four
11
+ * models trained on four corpora disagree about one sentence, and that is only convincing
12
+ * if you can type your own sentence and get an answer with no round trip to anywhere.
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ /* sklearn's default analyzer is `(?u)\b\w\w+\b` over the lowercased string. JavaScript's
18
+ * `\w` is ASCII-only, so the Unicode property escapes are spelled out to match Python. */
19
+ const TOKEN_RE = /(?<![\p{L}\p{N}_])[\p{L}\p{N}_]{2,}(?![\p{L}\p{N}_])/gu;
20
+
21
+ function tokenizeWords(text) {
22
+ return String(text).toLowerCase().match(TOKEN_RE) || [];
23
+ }
24
+
25
+ function ngrams(tokens, lo, hi) {
26
+ const out = lo <= 1 ? tokens.slice() : [];
27
+ for (let n = Math.max(2, lo); n <= hi; n += 1) {
28
+ for (let i = 0; i + n <= tokens.length; i += 1) {
29
+ out.push(tokens.slice(i, i + n).join(' '));
30
+ }
31
+ }
32
+ return out;
33
+ }
34
+
35
+ class TfidfModel {
36
+ constructor(payload) {
37
+ this.terms = payload.terms;
38
+ this.idf = payload.idf;
39
+ this.coef = payload.coef;
40
+ this.intercept = payload.intercept;
41
+ this.ngramRange = payload.ngram_range || [1, 2];
42
+ this.vocabulary = new Map();
43
+ for (let i = 0; i < this.terms.length; i += 1) this.vocabulary.set(this.terms[i], i);
44
+ }
45
+
46
+ /* Sparse by construction: a review touches a few hundred of thirty thousand columns,
47
+ * so the dense 30k vector of the Python version is never materialised here. */
48
+ transformOne(text) {
49
+ const counts = new Map();
50
+ const grams = ngrams(tokenizeWords(text), this.ngramRange[0], this.ngramRange[1]);
51
+ for (const gram of grams) {
52
+ const idx = this.vocabulary.get(gram);
53
+ if (idx !== undefined) counts.set(idx, (counts.get(idx) || 0) + 1);
54
+ }
55
+ const entries = [];
56
+ let sumsq = 0;
57
+ for (const [idx, count] of counts) {
58
+ const value = (1 + Math.log(count)) * this.idf[idx];
59
+ sumsq += value * value;
60
+ entries.push([idx, value]);
61
+ }
62
+ const norm = Math.sqrt(sumsq);
63
+ if (norm > 0) for (const e of entries) e[1] /= norm;
64
+ return entries;
65
+ }
66
+
67
+ score(text) {
68
+ const entries = this.transformOne(text);
69
+ let margin = this.intercept;
70
+ const contributions = [];
71
+ for (const [idx, value] of entries) {
72
+ const contribution = value * this.coef[idx];
73
+ margin += contribution;
74
+ contributions.push({ term: this.terms[idx], weight: contribution });
75
+ }
76
+ contributions.sort((a, b) => Math.abs(b.weight) - Math.abs(a.weight));
77
+ return {
78
+ margin,
79
+ probability: 1 / (1 + Math.exp(-margin)),
80
+ label: margin > 0 ? 'positive' : 'negative',
81
+ matched: entries.length,
82
+ terms: contributions.slice(0, 12),
83
+ };
84
+ }
85
+ }
86
+
87
+ /* ------------------------------------------------------------------ Node parity hook */
88
+ if (typeof module !== 'undefined' && module.exports) {
89
+ module.exports = { TfidfModel, tokenizeWords, ngrams };
90
+ }
91
+
92
+ /* --------------------------------------------------------------------------- the page */
93
+ if (typeof document !== 'undefined') {
94
+ const state = { data: null, models: {}, method: 'ft' };
95
+ const $ = (sel) => document.querySelector(sel);
96
+ const el = (tag, cls, text) => {
97
+ const node = document.createElement(tag);
98
+ if (cls) node.className = cls;
99
+ if (text !== undefined) node.textContent = text;
100
+ return node;
101
+ };
102
+ const pct = (x) => `${(x * 100).toFixed(1)}%`;
103
+ const pp = (x) => `${x >= 0 ? '+' : ''}${(x * 100).toFixed(1)} pp`;
104
+
105
+ async function boot() {
106
+ const [data, models] = await Promise.all([
107
+ fetch('data.json').then((r) => r.json()),
108
+ fetch('models.json').then((r) => r.json()),
109
+ ]);
110
+ state.data = data;
111
+ for (const [key, payload] of Object.entries(models)) {
112
+ state.models[key] = new TfidfModel(payload);
113
+ }
114
+
115
+ /* ?text= and ?method= make a particular view of the page linkable, which is also how
116
+ * the README screenshots are captured reproducibly rather than by hand. */
117
+ const params = new URLSearchParams(window.location.search);
118
+ if (params.has('text')) $('#text').value = params.get('text');
119
+ if (params.has('example')) {
120
+ const wanted = params.get('example').toLowerCase();
121
+ const match = data.examples.find((e) => e.label.toLowerCase() === wanted);
122
+ if (match) $('#text').value = match.text;
123
+ }
124
+ if (params.has('method') && data.methods.some((m) => m.key === params.get('method'))) {
125
+ state.method = params.get('method');
126
+ }
127
+
128
+ renderExamples();
129
+ renderScorer();
130
+ renderMethodTabs();
131
+ renderMatrix();
132
+ renderSummary();
133
+ renderDecomposition();
134
+ renderShiftAxes();
135
+ $('#loading').remove();
136
+ $('#app').hidden = false;
137
+ }
138
+
139
+ /* --------------------------------------------------------------- the live scorer */
140
+
141
+ function renderExamples() {
142
+ const box = $('#examples');
143
+ for (const example of state.data.examples) {
144
+ const chip = el('button', 'chip', example.label);
145
+ chip.type = 'button';
146
+ chip.addEventListener('click', () => {
147
+ $('#text').value = example.text;
148
+ renderScorer();
149
+ });
150
+ box.append(chip);
151
+ }
152
+ const clear = el('button', 'chip chip-ghost', 'Clear');
153
+ clear.type = 'button';
154
+ clear.addEventListener('click', () => {
155
+ $('#text').value = '';
156
+ renderScorer();
157
+ });
158
+ box.append(clear);
159
+ }
160
+
161
+ function renderScorer() {
162
+ const text = $('#text').value.trim();
163
+ const box = $('#verdicts');
164
+ box.textContent = '';
165
+ const labels = {};
166
+ for (const domain of state.data.domains) {
167
+ const model = state.models[domain.key];
168
+ if (!model) continue;
169
+ const result = text ? model.score(text) : null;
170
+ labels[domain.key] = result ? result.label : null;
171
+ box.append(verdictCard(domain, result));
172
+ }
173
+ const seen = new Set(Object.values(labels).filter(Boolean));
174
+ const note = $('#disagreement');
175
+ if (!text) {
176
+ note.className = 'note note-idle';
177
+ note.textContent =
178
+ 'Type a review, or pick one of the examples. Each card is a logistic model over '
179
+ + 'TF-IDF n-grams, trained on 6,000 reviews from one corpus and nothing else.';
180
+ } else if (seen.size > 1) {
181
+ const split = Object.entries(labels)
182
+ .filter(([, v]) => v)
183
+ .map(([k, v]) => `${state.data.domains.find((d) => d.key === k).label} says ${v}`)
184
+ .join(', ');
185
+ note.className = 'note note-split';
186
+ note.textContent = `The four models disagree. ${split}. Same sentence, same architecture — the only difference is which corpus each one read.`;
187
+ } else {
188
+ note.className = 'note note-agree';
189
+ note.textContent =
190
+ `All four agree this is ${[...seen][0]}. Agreement is the common case; the transfer `
191
+ + 'matrix below is where the disagreement shows up at scale.';
192
+ }
193
+ }
194
+
195
+ function verdictCard(domain, result) {
196
+ const card = el('article', 'verdict');
197
+ const head = el('header', 'verdict-head');
198
+ head.append(el('span', 'verdict-domain', domain.label));
199
+ head.append(el('span', 'verdict-meta', `${domain.topic} · ${domain.length}`));
200
+ card.append(head);
201
+
202
+ if (!result) {
203
+ card.append(el('p', 'verdict-empty', 'waiting for text'));
204
+ return card;
205
+ }
206
+
207
+ const positive = result.label === 'positive';
208
+ card.classList.add(positive ? 'is-positive' : 'is-negative');
209
+ const verdict = el('p', 'verdict-label', positive ? 'positive' : 'negative');
210
+ verdict.append(el('span', 'verdict-prob', ` ${pct(positive ? result.probability : 1 - result.probability)} confident`));
211
+ card.append(verdict);
212
+
213
+ const meter = el('div', 'meter');
214
+ const fill = el('div', 'meter-fill');
215
+ fill.style.width = `${Math.min(100, Math.max(0, result.probability * 100))}%`;
216
+ meter.append(fill);
217
+ const mid = el('div', 'meter-mid');
218
+ meter.append(mid);
219
+ card.append(meter);
220
+
221
+ const terms = el('ul', 'terms');
222
+ for (const t of result.terms.slice(0, 6)) {
223
+ const item = el('li', t.weight > 0 ? 'term term-pos' : 'term term-neg');
224
+ item.append(el('span', 'term-text', t.term));
225
+ item.append(el('span', 'term-weight', t.weight.toFixed(3)));
226
+ terms.append(item);
227
+ }
228
+ card.append(terms);
229
+ card.append(el('p', 'verdict-foot', `${result.matched} of its ${state.models[domain.key].terms.length.toLocaleString()} n-grams matched`));
230
+ return card;
231
+ }
232
+
233
+ /* ------------------------------------------------------------- the transfer matrix */
234
+
235
+ function renderMethodTabs() {
236
+ const box = $('#method-tabs');
237
+ for (const method of state.data.methods) {
238
+ if (!state.data.grid.some((r) => r.method === method.key)) continue;
239
+ const tab = el('button', 'tab', method.label);
240
+ tab.type = 'button';
241
+ tab.dataset.method = method.key;
242
+ tab.addEventListener('click', () => {
243
+ state.method = method.key;
244
+ renderMethodTabs();
245
+ renderMatrix();
246
+ });
247
+ if (method.key === state.method) tab.classList.add('is-active');
248
+ box.append(tab);
249
+ }
250
+ if (box.children.length > state.data.methods.length) return;
251
+ }
252
+
253
+ function renderMatrix() {
254
+ const box = $('#matrix');
255
+ box.textContent = '';
256
+ const domains = state.data.domains;
257
+ const cells = state.data.grid.filter((r) => r.method === state.method);
258
+ if (!cells.length) return;
259
+ const values = cells.map((c) => c.accuracy);
260
+ const lo = Math.min(...values);
261
+ const hi = Math.max(...values);
262
+
263
+ box.append(el('div', 'mcell mcell-corner', ''));
264
+ for (const d of domains) box.append(el('div', 'mcell mcell-head', d.label));
265
+
266
+ for (const train of domains) {
267
+ box.append(el('div', 'mcell mcell-head mcell-row', train.label));
268
+ for (const ev of domains) {
269
+ const cell = cells.find((c) => c.train === train.key && c.eval === ev.key);
270
+ const node = el('div', 'mcell mcell-value');
271
+ if (!cell) {
272
+ node.textContent = '–';
273
+ box.append(node);
274
+ continue;
275
+ }
276
+ const t = hi > lo ? (cell.accuracy - lo) / (hi - lo) : 0.5;
277
+ node.style.setProperty('--t', t.toFixed(3));
278
+ if (t > 0.62) node.classList.add('is-dark');
279
+ if (train.key === ev.key) node.classList.add('is-diagonal');
280
+ node.append(el('span', 'mcell-acc', pct(cell.accuracy)));
281
+ node.title =
282
+ `${state.method}: trained on ${train.label}, evaluated on ${ev.label}\n`
283
+ + `accuracy ${pct(cell.accuracy)} · AUROC ${pct(cell.auroc)}\n`
284
+ + `best reachable by moving the threshold ${pct(cell.oracle_accuracy)}\n`
285
+ + `predicts positive ${pct(cell.positive_rate)} of the time (truth is 50%)`;
286
+ box.append(node);
287
+ }
288
+ }
289
+ const blurb = state.data.methods.find((m) => m.key === state.method);
290
+ $('#matrix-blurb').textContent = blurb ? blurb.blurb : '';
291
+ }
292
+
293
+ /* -------------------------------------------------------------------- the summary */
294
+
295
+ function renderSummary() {
296
+ const body = $('#summary-body');
297
+ body.textContent = '';
298
+ for (const row of state.data.report.methods) {
299
+ const method = state.data.methods.find((m) => m.key === row.method);
300
+ const tr = el('tr');
301
+ tr.append(el('th', null, method ? method.label : row.method));
302
+ tr.append(el('td', null, pct(row.id_accuracy)));
303
+ tr.append(el('td', null, pct(row.ood_accuracy)));
304
+ const gap = el('td', 'num-gap', pp(-row.ood_gap));
305
+ tr.append(gap);
306
+ tr.append(el('td', null, pct(row.ood_auroc)));
307
+ tr.append(el('td', null, pct(row.ood_oracle_accuracy)));
308
+ tr.append(el('td', null, row.ood_ece.toFixed(3)));
309
+ body.append(tr);
310
+ }
311
+ }
312
+
313
+ /* ------------------------------------------------- what the gap is actually made of */
314
+
315
+ function renderDecomposition() {
316
+ const box = $('#decomposition');
317
+ box.textContent = '';
318
+ const rows = state.data.report.methods;
319
+ const worst = Math.max(...rows.map((r) => Math.max(0, r.ranking_drop) + Math.max(0, r.placement_drop)));
320
+ for (const row of rows) {
321
+ const method = state.data.methods.find((m) => m.key === row.method);
322
+ const item = el('div', 'decomp-row');
323
+ item.append(el('span', 'decomp-label', method ? method.label : row.method));
324
+ const bar = el('div', 'decomp-bar');
325
+ const ranking = Math.max(0, row.ranking_drop);
326
+ const placement = Math.max(0, row.placement_drop);
327
+ const scale = worst > 0 ? 100 / worst : 0;
328
+ const a = el('div', 'seg seg-ranking');
329
+ a.style.width = `${ranking * scale}%`;
330
+ a.title = `ranking: ${pp(ranking)} of accuracy the representation genuinely lost`;
331
+ const b = el('div', 'seg seg-placement');
332
+ b.style.width = `${placement * scale}%`;
333
+ b.title = `placement: ${pp(placement)} recoverable by moving the threshold alone`;
334
+ bar.append(a, b);
335
+ item.append(bar);
336
+ item.append(el('span', 'decomp-total', pp(-(ranking + placement))));
337
+ box.append(item);
338
+ }
339
+ }
340
+
341
+ /* ------------------------------------------------------- which shifts actually hurt */
342
+
343
+ function renderShiftAxes() {
344
+ const rows = state.data.report.shift_axes || [];
345
+ if (!rows.length) return;
346
+ const body = $('#shift-body');
347
+ const shifts = [...new Set(rows.map((r) => r.shift))].sort();
348
+ const head = $('#shift-head');
349
+ head.textContent = '';
350
+ head.append(el('th', null, 'method'));
351
+ for (const s of shifts) head.append(el('th', null, s.replace('topic-', 'topic ').replace('/length-', ' · length ')));
352
+ body.textContent = '';
353
+ for (const method of state.data.methods) {
354
+ if (!rows.some((r) => r.method === method.key)) continue;
355
+ const tr = el('tr');
356
+ tr.append(el('th', null, method.label));
357
+ for (const s of shifts) {
358
+ const row = rows.find((r) => r.method === method.key && r.shift === s);
359
+ tr.append(el('td', null, row ? pct(row.accuracy) : '–'));
360
+ }
361
+ body.append(tr);
362
+ }
363
+ }
364
+
365
+ document.addEventListener('DOMContentLoaded', () => {
366
+ $('#text').addEventListener('input', renderScorer);
367
+ boot().catch((err) => {
368
+ const box = $('#loading');
369
+ if (box) {
370
+ box.textContent = `Could not load the payload: ${err}. Run \`make web\` to build it.`;
371
+ }
372
+ });
373
+ });
374
+ }