kalfa 3.0.0.dev0__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.
Files changed (148) hide show
  1. kalfa/__init__.py +19 -0
  2. kalfa/api.py +284 -0
  3. kalfa/board/__init__.py +1 -0
  4. kalfa/board/answers.py +419 -0
  5. kalfa/board/server.py +536 -0
  6. kalfa/board/static/api.js +176 -0
  7. kalfa/board/static/app.js +122 -0
  8. kalfa/board/static/board.css +228 -0
  9. kalfa/board/static/charts.css +46 -0
  10. kalfa/board/static/components/badge.js +14 -0
  11. kalfa/board/static/components/chart.js +204 -0
  12. kalfa/board/static/components/diagram.js +857 -0
  13. kalfa/board/static/components/error.js +17 -0
  14. kalfa/board/static/components/loading.js +7 -0
  15. kalfa/board/static/components/progress.js +25 -0
  16. kalfa/board/static/components/settings.js +418 -0
  17. kalfa/board/static/components/spark.js +43 -0
  18. kalfa/board/static/components/table.js +156 -0
  19. kalfa/board/static/diagram.css +76 -0
  20. kalfa/board/static/format.js +138 -0
  21. kalfa/board/static/index.html +34 -0
  22. kalfa/board/static/plot.js +594 -0
  23. kalfa/board/static/predictions.css +20 -0
  24. kalfa/board/static/store.js +215 -0
  25. kalfa/board/static/views/compare.js +185 -0
  26. kalfa/board/static/views/home.js +90 -0
  27. kalfa/board/static/views/record/config.js +81 -0
  28. kalfa/board/static/views/record/curves.js +137 -0
  29. kalfa/board/static/views/record/data.js +378 -0
  30. kalfa/board/static/views/record/files.js +128 -0
  31. kalfa/board/static/views/record/log.js +164 -0
  32. kalfa/board/static/views/record/model.js +286 -0
  33. kalfa/board/static/views/record/overview.js +216 -0
  34. kalfa/board/static/views/record/plots.js +103 -0
  35. kalfa/board/static/views/record/predictions.js +834 -0
  36. kalfa/board/static/views/record.js +179 -0
  37. kalfa/board/static/views/runs.js +124 -0
  38. kalfa/board/static/views/sweep.js +282 -0
  39. kalfa/board/watch.py +87 -0
  40. kalfa/cli.py +371 -0
  41. kalfa/commands/__init__.py +0 -0
  42. kalfa/commands/common.py +8 -0
  43. kalfa/commands/export.py +117 -0
  44. kalfa/commands/generate.py +31 -0
  45. kalfa/commands/guide/__init__.py +101 -0
  46. kalfa/commands/guide/about.py +93 -0
  47. kalfa/commands/guide/calls.py +102 -0
  48. kalfa/commands/guide/sections.py +186 -0
  49. kalfa/commands/guide/seen.py +307 -0
  50. kalfa/commands/guide/show.py +106 -0
  51. kalfa/commands/guide/training.py +333 -0
  52. kalfa/commands/guide/words.py +97 -0
  53. kalfa/commands/legos.py +508 -0
  54. kalfa/commands/plots.py +27 -0
  55. kalfa/commands/report/__init__.py +64 -0
  56. kalfa/commands/report/page.py +112 -0
  57. kalfa/commands/report/predictions.py +216 -0
  58. kalfa/commands/report/sections.py +356 -0
  59. kalfa/commands/stop.py +60 -0
  60. kalfa/commands/sweep/__init__.py +202 -0
  61. kalfa/commands/sweep/checks.py +98 -0
  62. kalfa/commands/sweep/plan.py +183 -0
  63. kalfa/commands/sweep/points.py +123 -0
  64. kalfa/commands/sweep/runner.py +100 -0
  65. kalfa/commands/sweep/space.py +67 -0
  66. kalfa/commands/sweep/strategies.py +228 -0
  67. kalfa/core/__init__.py +0 -0
  68. kalfa/core/aliases.py +229 -0
  69. kalfa/core/build.py +727 -0
  70. kalfa/core/check.py +550 -0
  71. kalfa/core/coerce.py +36 -0
  72. kalfa/core/context.py +149 -0
  73. kalfa/core/device.py +63 -0
  74. kalfa/core/errors.py +45 -0
  75. kalfa/core/graph.py +304 -0
  76. kalfa/core/history.py +70 -0
  77. kalfa/core/loop.py +710 -0
  78. kalfa/core/make.py +121 -0
  79. kalfa/core/plan.py +156 -0
  80. kalfa/core/predict.py +290 -0
  81. kalfa/core/protocols.py +122 -0
  82. kalfa/core/record.py +347 -0
  83. kalfa/core/refs.py +88 -0
  84. kalfa/core/rng.py +100 -0
  85. kalfa/core/rules.py +513 -0
  86. kalfa/core/run.py +568 -0
  87. kalfa/core/spec.py +413 -0
  88. kalfa/core/state.py +100 -0
  89. kalfa/core/wrap.py +671 -0
  90. kalfa/records/__init__.py +24 -0
  91. kalfa/records/compare.py +86 -0
  92. kalfa/records/reader.py +561 -0
  93. kalfa/records/stats/__init__.py +36 -0
  94. kalfa/records/stats/choices.py +282 -0
  95. kalfa/records/stats/classes.py +236 -0
  96. kalfa/records/stats/measures.py +144 -0
  97. kalfa/records/stats/pairs.py +225 -0
  98. kalfa/records/stats/tables.py +153 -0
  99. kalfa/records/sweep.py +244 -0
  100. kalfa/std/__init__.py +17 -0
  101. kalfa/std/actions.py +35 -0
  102. kalfa/std/common.py +56 -0
  103. kalfa/std/data/__init__.py +1 -0
  104. kalfa/std/data/base.py +45 -0
  105. kalfa/std/data/columns.py +190 -0
  106. kalfa/std/data/flow.py +317 -0
  107. kalfa/std/data/images.py +443 -0
  108. kalfa/std/data/options.py +147 -0
  109. kalfa/std/data/pre.py +84 -0
  110. kalfa/std/data/splits.py +140 -0
  111. kalfa/std/data/stream.py +294 -0
  112. kalfa/std/data/table.py +642 -0
  113. kalfa/std/data/text.py +436 -0
  114. kalfa/std/data/transforms.py +327 -0
  115. kalfa/std/data/weights.py +113 -0
  116. kalfa/std/data/window.py +135 -0
  117. kalfa/std/figure.py +37 -0
  118. kalfa/std/init.py +72 -0
  119. kalfa/std/loggers.py +105 -0
  120. kalfa/std/losses.py +161 -0
  121. kalfa/std/metrics.py +463 -0
  122. kalfa/std/models.py +122 -0
  123. kalfa/std/notes.py +1553 -0
  124. kalfa/std/objectives.py +360 -0
  125. kalfa/std/plots/__init__.py +75 -0
  126. kalfa/std/plots/classification.py +207 -0
  127. kalfa/std/plots/common.py +44 -0
  128. kalfa/std/plots/curves.py +56 -0
  129. kalfa/std/plots/images.py +188 -0
  130. kalfa/std/plots/pairs.py +66 -0
  131. kalfa/std/plots/predictions.py +154 -0
  132. kalfa/std/samplers.py +261 -0
  133. kalfa/std/samples.py +72 -0
  134. kalfa/std/schedules.py +74 -0
  135. kalfa/std/steps.py +133 -0
  136. kalfa/std/triggers.py +135 -0
  137. kalfa/summary.py +296 -0
  138. kalfa/tui/__init__.py +4 -0
  139. kalfa/tui/app.py +107 -0
  140. kalfa/tui/config.py +61 -0
  141. kalfa/tui/guide.py +100 -0
  142. kalfa/tui/jobs.py +97 -0
  143. kalfa/tui/legos.py +62 -0
  144. kalfa/tui/records.py +439 -0
  145. kalfa-3.0.0.dev0.dist-info/METADATA +261 -0
  146. kalfa-3.0.0.dev0.dist-info/RECORD +148 -0
  147. kalfa-3.0.0.dev0.dist-info/WHEEL +4 -0
  148. kalfa-3.0.0.dev0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,834 @@
1
+ import Chart from "../../components/chart.js";
2
+ import { messageOf } from "../../components/error.js";
3
+ import Table from "../../components/table.js";
4
+ import { integer, number, percent } from "../../format.js";
5
+
6
+
7
+ const DEFAULT = "predictions.parquet";
8
+ const PREFIX = "kalfa.predictions.";
9
+ const DELAY = 350;
10
+ const GROUPS = 20;
11
+ const CHOICES = ["task", "score", "positive", "threshold"];
12
+ const HISTOGRAMS = {
13
+ residuals: "residual_hist",
14
+ histograms: "truth_hist",
15
+ scores: "score_hist",
16
+ norms: "norm_hist",
17
+ values: "hist",
18
+ };
19
+ const TASK_HISTOGRAMS = {
20
+ regression: ["residuals", "histograms"],
21
+ binary: ["scores"],
22
+ embedding: ["norms"],
23
+ values: ["values"],
24
+ };
25
+ const WORST_COLUMNS = [
26
+ { key: "row", label: "row", numeric: true },
27
+ { key: "truth", label: "truth", numeric: true },
28
+ { key: "pred", label: "prediction", numeric: true },
29
+ { key: "error", label: "error", numeric: true },
30
+ ];
31
+ const CLASS_COLUMNS = [
32
+ { key: "label", label: "class", className: "mono" },
33
+ { key: "precision", label: "precision", numeric: true, text: row => percent(row.precision, 1) },
34
+ { key: "recall", label: "recall", numeric: true, text: row => percent(row.recall, 1) },
35
+ { key: "f1", label: "f1", numeric: true, text: row => number(row.f1) },
36
+ { key: "support", label: "support", numeric: true, text: row => integer(row.support) },
37
+ ];
38
+ const REASONS = {
39
+ "binary,multiclass": "the target has two classes",
40
+ "binary,regression": "the numeric target holds two values and the larger one is the positive class",
41
+ "multiclass,regression": "the target holds 3 to 20 whole numbers and every prediction is one of them",
42
+ multiclass: "the target holds class labels",
43
+ regression: "the target is numeric",
44
+ embedding: "spread pred_ columns with no target beside them",
45
+ values: "one pred_ column with no target beside it",
46
+ };
47
+ const NOTES = {
48
+ binary: "two classes: the confusion matrix at a threshold, and ROC, precision recall and a threshold sweep when a score column exists",
49
+ multiclass: "several classes: the confusion matrix, precision, recall and f1 per class, and ROC when every class has a prob_ column",
50
+ regression: "a numeric target: rmse, mae, r2, truth against prediction and the residuals",
51
+ embedding: "a vector per row: a PCA map of the rows and the length of each vector",
52
+ values: "one value per row: its distribution",
53
+ };
54
+ const RULES = {
55
+ "0.5": "0.5, the score is a probability",
56
+ best_f1: "the best f1 on this table",
57
+ given: "set here",
58
+ };
59
+
60
+ function tablesOf(record) {
61
+ return record && Array.isArray(record.tables) ? record.tables : [];
62
+ }
63
+
64
+ function chosenTable(tables, current) {
65
+ if (!tables.length) return DEFAULT;
66
+ if (tables.includes(current)) return current;
67
+ return tables.includes(DEFAULT) ? DEFAULT : tables[0];
68
+ }
69
+
70
+ function readStored(key) {
71
+ try {
72
+ const text = window.localStorage.getItem(PREFIX + key);
73
+ const value = text ? JSON.parse(text) : null;
74
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
75
+ } catch (error) {
76
+ return {};
77
+ }
78
+ }
79
+
80
+ function writeStored(key, value) {
81
+ try {
82
+ if (value && Object.keys(value).length) window.localStorage.setItem(PREFIX + key, JSON.stringify(value));
83
+ else window.localStorage.removeItem(PREFIX + key);
84
+ } catch (error) {
85
+ return;
86
+ }
87
+ }
88
+
89
+ function cleaned(choice) {
90
+ const found = {};
91
+ for (const key of CHOICES) {
92
+ const value = choice ? choice[key] : null;
93
+ if (value !== null && value !== undefined && value !== "") found[key] = value;
94
+ }
95
+ return found;
96
+ }
97
+
98
+ function stairs(edges, counts) {
99
+ const x = [];
100
+ const y = [];
101
+ (counts || []).forEach((count, index) => {
102
+ x.push(edges[index], edges[index + 1]);
103
+ y.push(count, count);
104
+ });
105
+ return { x, y };
106
+ }
107
+
108
+ function peakOf(lists) {
109
+ let peak = 0;
110
+ for (const list of lists) {
111
+ for (const value of list || []) {
112
+ if (typeof value === "number" && value > peak) peak = value;
113
+ }
114
+ }
115
+ return peak || 1;
116
+ }
117
+
118
+ function shortened(matrix) {
119
+ return (matrix || []).map((row) => row.map((value) => (typeof value === "number" ? Number(value.toFixed(3)) : value)));
120
+ }
121
+
122
+ function labelOf(value) {
123
+ return value === null || value === undefined ? "missing" : String(value);
124
+ }
125
+
126
+ function rowText(row, index) {
127
+ return "row " + labelOf(row) + ", index " + index;
128
+ }
129
+
130
+ function pcaGroups(pca) {
131
+ const xs = pca.x || [];
132
+ const ys = pca.y || [];
133
+ const rows = pca.row || [];
134
+ const indexes = pca.index || [];
135
+ const colors = Array.isArray(pca.color) ? pca.color.map(labelOf) : null;
136
+ const names = colors ? [...new Set(colors)] : [];
137
+ if (!colors || names.length > GROUPS) {
138
+ return [{ name: "rows", x: xs, y: ys, text: xs.map((value, index) => rowText(rows[index], indexes[index])) }];
139
+ }
140
+ return names.map((name) => {
141
+ const group = { name, x: [], y: [], text: [] };
142
+ colors.forEach((color, index) => {
143
+ if (color !== name) return;
144
+ group.x.push(xs[index]);
145
+ group.y.push(ys[index]);
146
+ group.text.push(name + ", " + rowText(rows[index], indexes[index]));
147
+ });
148
+ return group;
149
+ });
150
+ }
151
+
152
+ export default {
153
+ name: "PredictionsTab",
154
+ components: { "k-chart": Chart, "k-table": Table },
155
+ props: {
156
+ record: { type: Object, default: null },
157
+ path: { type: String, default: "" },
158
+ api: { default: null },
159
+ version: { type: Number, default: 0 },
160
+ },
161
+ data() {
162
+ return {
163
+ stats: null,
164
+ error: "",
165
+ loading: false,
166
+ where: "",
167
+ applied: "",
168
+ name: chosenTable(tablesOf(this.record), DEFAULT),
169
+ selected: "",
170
+ choice: {},
171
+ slider: null,
172
+ binsByChart: {},
173
+ matrix: "counts",
174
+ metric: "f1",
175
+ };
176
+ },
177
+ computed: {
178
+ worstColumns() { return WORST_COLUMNS; },
179
+ classColumns() { return CLASS_COLUMNS; },
180
+ tables() { return tablesOf(this.record); },
181
+ manyTables() { return this.tables.length > 1; },
182
+ available() { return Boolean(this.record && (this.record.has_predictions || this.tables.length)); },
183
+ tablesKey() { return (this.available ? "1:" : "0:") + this.tables.join(","); },
184
+ pairs() { return this.stats && Array.isArray(this.stats.pairs) ? this.stats.pairs : []; },
185
+ manyPairs() { return this.pairs.length > 1; },
186
+ pair() { return this.pairs.find((item) => item.pred === this.selected) || this.pairs[0] || null; },
187
+ numbers() { return (this.pair && this.pair.stats) || {}; },
188
+ task() { return this.pair ? this.pair.task : ""; },
189
+ isRegression() { return this.task === "regression"; },
190
+ isBinary() { return this.task === "binary"; },
191
+ isMulticlass() { return this.task === "multiclass"; },
192
+ isEmbedding() { return this.task === "embedding"; },
193
+ isValues() { return this.task === "values"; },
194
+ chosen() { return Object.keys(this.choice).length > 0; },
195
+ noPairs() { return Boolean(this.stats) && !this.pairs.length; },
196
+ rowsText() {
197
+ if (!this.stats) return "";
198
+ return integer(this.stats.rows) + " rows" + (this.applied ? " where " + this.applied : "");
199
+ },
200
+ overview() { return (this.stats && this.stats.overview) || { pairs: 0, tasks: {}, rows: [] }; },
201
+ overviewRows() { return Array.isArray(this.overview.rows) ? this.overview.rows : []; },
202
+ overviewNote() {
203
+ const parts = Object.entries(this.overview.tasks || {}).map(([task, count]) => count + " " + task);
204
+ return this.overviewRows.length + " pairs: " + parts.join(", ") + "; click a row to open it";
205
+ },
206
+ pairNote() {
207
+ const item = this.pair;
208
+ if (!item) return "";
209
+ if (item.target) return item.pred + " against " + item.target;
210
+ const last = item.columns[item.columns.length - 1];
211
+ return item.columns.length > 1 ? item.columns.length + " columns from " + item.columns[0] + " to " + last : item.pred;
212
+ },
213
+ reason() {
214
+ const item = this.pair;
215
+ if (!item) return "";
216
+ const why = REASONS[item.tasks.join(",")] || "";
217
+ const head = "auto reads it as " + item.auto + (why ? " since " + why : "");
218
+ return item.task === item.auto ? head : head + "; you chose " + item.task;
219
+ },
220
+ taskNote() { return NOTES[this.task] || ""; },
221
+ taskOptions() {
222
+ const item = this.pair;
223
+ if (!item) return [];
224
+ return [{ value: "", label: "auto, " + item.auto }, ...item.tasks.map((task) => ({ value: task, label: task }))];
225
+ },
226
+ taskChoice() { return this.choice.task || ""; },
227
+ scoreOptions() { return Array.isArray(this.numbers.candidates) ? this.numbers.candidates : []; },
228
+ positiveOptions() { return Array.isArray(this.numbers.labels) ? this.numbers.labels : []; },
229
+ range() {
230
+ const sweep = this.numbers.sweep;
231
+ const steps = sweep && Array.isArray(sweep.threshold) ? sweep.threshold : [];
232
+ const low = steps.length ? steps[0] : 0;
233
+ const high = steps.length ? steps[steps.length - 1] : 1;
234
+ const top = high > low ? high : low + 1;
235
+ return { min: low, max: top, step: (top - low) / 200 };
236
+ },
237
+ sliderText() { return number(this.slider); },
238
+ ruleText() { return RULES[this.numbers.threshold_rule] || ""; },
239
+ metrics() {
240
+ const s = this.numbers;
241
+ if (this.isRegression) {
242
+ return [["rows used", integer(s.count)], ["rmse", number(s.rmse)], ["mae", number(s.mae)], ["r2", number(s.r2)],
243
+ ["bias", number(s.bias)]];
244
+ }
245
+ if (this.isBinary) {
246
+ const found = [["rows used", integer(s.count)], ["accuracy", percent(s.accuracy, 1)],
247
+ ["precision", percent(s.precision, 1)], ["recall", percent(s.recall, 1)], ["f1", number(s.f1)],
248
+ ["specificity", percent(s.specificity, 1)], ["balanced accuracy", percent(s.balanced_accuracy, 1)]];
249
+ if (s.roc) found.push(["roc auc", number(s.roc.auc)]);
250
+ if (s.pr) found.push(["average precision", number(s.pr.ap)]);
251
+ return found;
252
+ }
253
+ if (this.isMulticlass) {
254
+ return [["rows used", integer(s.count)], ["classes", integer((s.classes || []).length)],
255
+ ["accuracy", percent(s.accuracy, 1)], ["macro f1", number(s.macro_f1)], ["weighted f1", number(s.weighted_f1)]];
256
+ }
257
+ if (this.isEmbedding) {
258
+ const explained = (s.pca && s.pca.explained) || [];
259
+ return [["rows used", integer(s.count)], ["dimensions", integer(s.dims)],
260
+ ["first component", percent(explained[0], 1)], ["second component", percent(explained[1], 1)]];
261
+ }
262
+ if (this.isValues) {
263
+ if (s.labels) return [["rows used", integer(s.count)], ["labels", integer((s.labels.labels || []).length)]];
264
+ return [["rows used", integer(s.count)], ["mean", number(s.mean)], ["std", number(s.std)]];
265
+ }
266
+ return [];
267
+ },
268
+ quantiles() {
269
+ return Object.entries(this.numbers.residual_quantiles || {}).map(([name, value]) => ({ name, value: number(value) }));
270
+ },
271
+ worst() { return Array.isArray(this.numbers.worst) ? this.numbers.worst : []; },
272
+ scatterData() {
273
+ const scatter = this.isRegression ? this.numbers.scatter : null;
274
+ if (!scatter) return null;
275
+ return [{ name: "rows", x: scatter.truth, y: scatter.pred, text: (scatter.row || []).map((row) => "row " + row) }];
276
+ },
277
+ scatterOptions() {
278
+ const item = this.pair || {};
279
+ return { diagonal: true, xlabel: "truth, " + item.target, ylabel: "prediction, " + item.pred, legend: false };
280
+ },
281
+ residualData() {
282
+ const found = this.isRegression ? this.numbers.residual_hist : null;
283
+ return found ? { edges: found.edges || [], counts: found.counts || [] } : null;
284
+ },
285
+ residualOptions() { return { xlabel: "prediction minus truth", ylabel: "rows" }; },
286
+ histogramData() {
287
+ const s = this.numbers;
288
+ if (!this.isRegression || !s.truth_hist || !s.pred_hist) return null;
289
+ return [
290
+ { name: "truth", edges: s.truth_hist.edges || [], counts: s.truth_hist.counts || [] },
291
+ { name: "prediction", edges: s.pred_hist.edges || [], counts: s.pred_hist.counts || [] },
292
+ ];
293
+ },
294
+ histogramOptions() { return { xlabel: this.pair ? this.pair.target : "", ylabel: "rows" }; },
295
+ matrixLabels() {
296
+ const s = this.numbers;
297
+ if (this.isBinary) return Array.isArray(s.labels) ? s.labels : [];
298
+ return Array.isArray(s.classes) ? s.classes : [];
299
+ },
300
+ confusionData() {
301
+ const s = this.numbers;
302
+ if (!(this.isBinary || this.isMulticlass) || !Array.isArray(s.confusion)) return null;
303
+ const z = this.matrix === "rates" ? shortened(s.confusion_normalized) : s.confusion;
304
+ return { z, x: this.matrixLabels, y: this.matrixLabels };
305
+ },
306
+ confusionTitle() {
307
+ const head = this.matrix === "rates" ? "confusion, share of each true class" : "confusion, rows";
308
+ return this.isBinary && this.numbers.score ? head + " at threshold " + number(this.numbers.threshold) : head;
309
+ },
310
+ confusionOptions() {
311
+ const item = this.pair || {};
312
+ return { xlabel: "predicted, " + item.pred, ylabel: "truth, " + item.target };
313
+ },
314
+ rocData() {
315
+ const roc = this.isBinary ? this.numbers.roc : null;
316
+ if (!roc) return null;
317
+ return [{ name: "roc", x: roc.fpr, y: roc.tpr, mode: "lines" },
318
+ { name: "chance", x: [0, 1], y: [0, 1], dash: "dot", mode: "lines" }];
319
+ },
320
+ rocTitle() { return this.numbers.roc && this.isBinary ? "ROC, auc " + number(this.numbers.roc.auc) : ""; },
321
+ rocOptions() { return { xlabel: "false positive rate", ylabel: "true positive rate" }; },
322
+ prData() {
323
+ const pr = this.isBinary ? this.numbers.pr : null;
324
+ return pr ? [{ name: "precision recall", x: pr.recall, y: pr.precision, mode: "lines" }] : null;
325
+ },
326
+ prTitle() { return this.numbers.pr ? "precision recall, average precision " + number(this.numbers.pr.ap) : ""; },
327
+ prOptions() { return { xlabel: "recall", ylabel: "precision", legend: false }; },
328
+ scoreData() {
329
+ const found = this.isBinary ? this.numbers.score_hist : null;
330
+ if (!found) return null;
331
+ const labels = this.positiveOptions;
332
+ const negative = stairs(found.edges || [], found.negative);
333
+ const positive = stairs(found.edges || [], found.positive);
334
+ const peak = peakOf([found.negative, found.positive]);
335
+ const at = this.numbers.threshold;
336
+ return [
337
+ { name: "truth " + labelOf(labels[0]), x: negative.x, y: negative.y, mode: "lines" },
338
+ { name: "truth " + labelOf(labels[1]), x: positive.x, y: positive.y, mode: "lines" },
339
+ { name: "threshold", x: [at, at], y: [0, peak], mode: "lines", dash: "dash" },
340
+ ];
341
+ },
342
+ scoreChartOptions() { return { xlabel: "score, " + (this.numbers.score || ""), ylabel: "rows" }; },
343
+ sweepData() {
344
+ const sweep = this.isBinary ? this.numbers.sweep : null;
345
+ if (!sweep) return null;
346
+ const at = this.numbers.threshold;
347
+ return [
348
+ { name: "precision", x: sweep.threshold, y: sweep.precision, mode: "lines" },
349
+ { name: "recall", x: sweep.threshold, y: sweep.recall, mode: "lines" },
350
+ { name: "f1", x: sweep.threshold, y: sweep.f1, mode: "lines" },
351
+ { name: "threshold", x: [at, at], y: [0, 1], mode: "lines", dash: "dash" },
352
+ ];
353
+ },
354
+ sweepOptions() { return { xlabel: "threshold", ylabel: "rate" }; },
355
+ perClass() {
356
+ return Object.entries(this.numbers.per_class || {}).map(([label, item]) => ({ label, ...(item || {}) }));
357
+ },
358
+ classBars() {
359
+ if (!this.isMulticlass || !this.perClass.length) return null;
360
+ return { labels: this.perClass.map((row) => row.label), values: this.perClass.map((row) => row[this.metric]) };
361
+ },
362
+ classBarsOptions() { return { horizontal: true, xlabel: this.metric }; },
363
+ classBarsHeight() { return Math.max(220, Math.min(640, 60 + 26 * this.perClass.length)); },
364
+ multiRocData() {
365
+ const roc = this.isMulticlass ? this.numbers.roc : null;
366
+ if (!roc || typeof roc !== "object") return null;
367
+ const lines = Object.entries(roc).map(([label, curve]) => ({
368
+ name: label + ", auc " + number(curve.auc), x: curve.fpr, y: curve.tpr, mode: "lines",
369
+ }));
370
+ lines.push({ name: "chance", x: [0, 1], y: [0, 1], dash: "dot", mode: "lines" });
371
+ return lines;
372
+ },
373
+ multiRocOptions() { return { xlabel: "false positive rate", ylabel: "true positive rate" }; },
374
+ pcaData() {
375
+ const pca = this.isEmbedding ? this.numbers.pca : null;
376
+ return pca && Array.isArray(pca.x) && pca.x.length ? pcaGroups(pca) : null;
377
+ },
378
+ pcaTitle() {
379
+ const pca = this.numbers.pca || {};
380
+ const shown = (pca.x || []).length;
381
+ const base = "PCA of " + (this.pair ? this.pair.pred : "") + ", " + integer(shown) + " rows";
382
+ return pca.color_by ? base + " colored by " + pca.color_by : base;
383
+ },
384
+ pcaOptions() {
385
+ const explained = (this.numbers.pca && this.numbers.pca.explained) || [];
386
+ return { xlabel: "first component, " + percent(explained[0], 1), ylabel: "second component, " + percent(explained[1], 1) };
387
+ },
388
+ normData() {
389
+ const found = this.isEmbedding ? this.numbers.norm_hist : null;
390
+ return found ? { edges: found.edges || [], counts: found.counts || [] } : null;
391
+ },
392
+ normOptions() { return { xlabel: "vector length", ylabel: "rows" }; },
393
+ valuesData() {
394
+ const found = this.isValues ? this.numbers.hist : null;
395
+ return found ? { edges: found.edges || [], counts: found.counts || [] } : null;
396
+ },
397
+ valuesOptions() { return { xlabel: this.pair ? this.pair.pred : "", ylabel: "rows" }; },
398
+ labelBars() {
399
+ const found = this.isValues ? this.numbers.labels : null;
400
+ return found ? { labels: found.labels || [], values: found.counts || [] } : null;
401
+ },
402
+ labelBarsOptions() { return { horizontal: true, xlabel: "rows" }; },
403
+ labelBarsHeight() {
404
+ const found = this.labelBars ? this.labelBars.labels.length : 0;
405
+ return Math.max(220, Math.min(640, 60 + 24 * found));
406
+ },
407
+ valueQuantiles() {
408
+ return Object.entries(this.numbers.quantiles || {}).map(([name, value]) => ({ name, value: number(value) }));
409
+ },
410
+ gallery() {
411
+ return this.pair && this.pair.images && Array.isArray(this.numbers.gallery) ? this.numbers.gallery : [];
412
+ },
413
+ galleryNote() {
414
+ if (this.isBinary || this.isMulticlass) return "wrong predictions first";
415
+ if (this.isRegression) return "largest errors first";
416
+ return "the first rows";
417
+ },
418
+ histograms() {
419
+ const item = this.pair;
420
+ if (!item || item.pred !== this.selected) return {};
421
+ const found = {};
422
+ for (const chart of TASK_HISTOGRAMS[this.choice.task || item.auto] || []) {
423
+ const count = this.binsByChart[this.chartKey(chart)];
424
+ if (count) found[HISTOGRAMS[chart]] = count;
425
+ }
426
+ return found;
427
+ },
428
+ histogramsText() {
429
+ return Object.entries(this.histograms).map(([name, count]) => name + ":" + count).join(",");
430
+ },
431
+ histogramsWanted() { return this.histogramsText ? this.selected + "|" + this.histogramsText : ""; },
432
+ },
433
+ watch: {
434
+ tablesKey() {
435
+ const wanted = chosenTable(this.tables, this.name);
436
+ if (wanted !== this.name) {
437
+ this.name = wanted;
438
+ return;
439
+ }
440
+ this.load();
441
+ },
442
+ name() {
443
+ this.restore();
444
+ this.load();
445
+ },
446
+ path() {
447
+ this.restore();
448
+ this.load();
449
+ },
450
+ histogramsWanted() {
451
+ this.syncBins();
452
+ },
453
+ },
454
+ created() {
455
+ this.timer = null;
456
+ this.ticket = 0;
457
+ this.sent = { pred: "", text: "" };
458
+ this.binsQueued = false;
459
+ },
460
+ mounted() {
461
+ this.restore();
462
+ this.load();
463
+ },
464
+ beforeUnmount() {
465
+ if (this.timer) clearTimeout(this.timer);
466
+ },
467
+ methods: {
468
+ number,
469
+ percent,
470
+ integer,
471
+ tableKey() { return this.path + "|" + this.name; },
472
+ pairKey(pred) { return this.tableKey() + "|" + pred; },
473
+ chartKey(chart) { return "predictions|" + this.path + "|" + (this.pair ? this.pair.pred : "") + "|" + chart; },
474
+ restore() {
475
+ this.selected = String(readStored(this.tableKey()).pred || "");
476
+ this.choice = cleaned(readStored(this.pairKey(this.selected)));
477
+ },
478
+ pick(pred) {
479
+ if (this.pair && pred === this.pair.pred) return;
480
+ const before = this.chosen;
481
+ this.selected = pred;
482
+ writeStored(this.tableKey(), { pred });
483
+ this.choice = cleaned(readStored(this.pairKey(pred)));
484
+ this.syncSlider();
485
+ if (before || this.chosen) this.load();
486
+ },
487
+ remember(patch) {
488
+ if (!this.pair) return;
489
+ if (this.timer) clearTimeout(this.timer);
490
+ this.timer = null;
491
+ this.selected = this.pair.pred;
492
+ this.choice = cleaned({ ...this.choice, ...patch });
493
+ writeStored(this.tableKey(), { pred: this.selected });
494
+ writeStored(this.pairKey(this.selected), this.choice);
495
+ this.load();
496
+ },
497
+ setTask(value) { this.remember({ task: value, threshold: null }); },
498
+ setScore(value) { this.remember({ score: value, threshold: null }); },
499
+ setPositive(value) { this.remember({ positive: value, score: null, threshold: null }); },
500
+ slide(value) {
501
+ const found = Number(value);
502
+ if (!this.pair || !Number.isFinite(found)) return;
503
+ const key = this.pairKey(this.pair.pred);
504
+ this.slider = found;
505
+ if (this.timer) clearTimeout(this.timer);
506
+ this.timer = setTimeout(() => {
507
+ this.timer = null;
508
+ if (this.pair && this.pairKey(this.pair.pred) === key) this.remember({ threshold: found });
509
+ }, DELAY);
510
+ },
511
+ resetThreshold() { this.remember({ threshold: null }); },
512
+ resetChoice() {
513
+ if (!this.pair) return;
514
+ if (this.timer) clearTimeout(this.timer);
515
+ this.timer = null;
516
+ this.choice = {};
517
+ writeStored(this.pairKey(this.pair.pred), {});
518
+ this.load();
519
+ },
520
+ setMatrix(mode) { this.matrix = mode; },
521
+ setMetric(metric) { this.metric = metric; },
522
+ syncSlider() {
523
+ const found = this.pair && this.pair.task === "binary" ? this.pair.stats.threshold : null;
524
+ this.slider = typeof found === "number" ? found : null;
525
+ },
526
+ onSettings(payload) {
527
+ if (!payload || typeof payload.key !== "string") return;
528
+ const wanted = Number.isInteger(payload.bins) && payload.bins > 0 ? payload.bins : null;
529
+ const known = this.binsByChart[payload.key] || null;
530
+ if (wanted === known) return;
531
+ if (wanted === null) delete this.binsByChart[payload.key];
532
+ else this.binsByChart[payload.key] = wanted;
533
+ },
534
+ countedText() {
535
+ return this.pair && this.sent.pred === this.pair.pred ? this.sent.text : "";
536
+ },
537
+ syncBins() {
538
+ if (this.binsQueued) return;
539
+ this.binsQueued = true;
540
+ this.$nextTick(() => {
541
+ this.binsQueued = false;
542
+ if (this.histogramsText !== this.countedText()) this.load();
543
+ });
544
+ },
545
+ apply() {
546
+ this.applied = this.where.trim();
547
+ this.load();
548
+ },
549
+ clear() {
550
+ this.where = "";
551
+ this.applied = "";
552
+ this.load();
553
+ },
554
+ pairLabel(item) {
555
+ if (item.target) return item.pred + " against " + item.target;
556
+ return item.columns.length > 1 ? item.pred + ", " + item.columns.length + " columns" : item.pred;
557
+ },
558
+ valueText(row) {
559
+ if (row.value === null || row.value === undefined) return "none";
560
+ if (row.key === "count") return integer(row.value);
561
+ if (row.key === "accuracy" || row.key === "explained") return percent(row.value, 1);
562
+ return number(row.value);
563
+ },
564
+ imageUrl(entry) {
565
+ return this.api ? this.api.imageUrl(this.path, entry.index, this.name) : "";
566
+ },
567
+ entryWrong(entry) {
568
+ return (this.isBinary || this.isMulticlass) && entry.truth !== entry.pred;
569
+ },
570
+ entryText(entry) {
571
+ const parts = [];
572
+ if (entry.truth !== null && entry.truth !== undefined) parts.push("truth " + labelOf(typeof entry.truth === "number" ? number(entry.truth) : entry.truth));
573
+ if (entry.pred !== null && entry.pred !== undefined) parts.push("pred " + labelOf(typeof entry.pred === "number" ? number(entry.pred) : entry.pred));
574
+ return parts.join(", ") || "row " + labelOf(entry.row);
575
+ },
576
+ entryDetail(entry) {
577
+ if (entry.error !== null && entry.error !== undefined) return "error " + number(entry.error);
578
+ if (entry.score !== null && entry.score !== undefined) return "score " + number(entry.score);
579
+ return "row " + labelOf(entry.row);
580
+ },
581
+ params() {
582
+ const params = { where: this.applied || null, name: this.name || null };
583
+ if (this.chosen && this.selected) {
584
+ params.pair = this.selected;
585
+ for (const key of CHOICES) {
586
+ if (this.choice[key] !== undefined) params[key] = this.choice[key];
587
+ }
588
+ }
589
+ if (this.histogramsText) {
590
+ params.pair = this.selected;
591
+ params.histograms = { ...this.histograms };
592
+ }
593
+ return params;
594
+ },
595
+ async load() {
596
+ if (!this.api) {
597
+ this.error = "the api prop is missing";
598
+ return;
599
+ }
600
+ if (!this.available) {
601
+ this.stats = null;
602
+ return;
603
+ }
604
+ const path = this.path;
605
+ const ticket = ++this.ticket;
606
+ const chosen = this.chosen;
607
+ const params = this.params();
608
+ this.sent = { pred: params.pair || "", text: this.histogramsText };
609
+ this.loading = true;
610
+ try {
611
+ const found = await this.api.getPredictions(path, params);
612
+ if (path !== this.path || ticket !== this.ticket) return;
613
+ this.stats = found ? Object.freeze(found) : null;
614
+ this.error = "";
615
+ if (this.pairs.length && !this.pairs.some((item) => item.pred === this.selected)) {
616
+ this.selected = this.pairs[0].pred;
617
+ this.choice = cleaned(readStored(this.pairKey(this.selected)));
618
+ if (this.chosen) {
619
+ this.load();
620
+ return;
621
+ }
622
+ }
623
+ this.syncSlider();
624
+ } catch (error) {
625
+ if (path !== this.path || ticket !== this.ticket) return;
626
+ if (chosen && params.pair && error.status === 400 && !messageOf(error).startsWith("bad where")) {
627
+ this.choice = {};
628
+ writeStored(this.pairKey(params.pair), {});
629
+ this.load();
630
+ return;
631
+ }
632
+ this.stats = null;
633
+ this.error = messageOf(error);
634
+ } finally {
635
+ if (ticket === this.ticket) this.loading = false;
636
+ }
637
+ },
638
+ },
639
+ template: `
640
+ <div class="page pane-predictions">
641
+ <div class="muted small">The prediction table of the record, scored on the server. Each pred_ column pairs with its target
642
+ and gets a task: binary, multiclass, regression, embedding for spread columns without a target, or values for a single
643
+ column without one. The task selector reads a pair another way, a binary pair takes its score column, positive class and
644
+ threshold, and the choices stay in this browser for each record, table and pair. A where filter is a pandas query over the
645
+ columns, for example price &gt; 200 and town == 'ank'.</div>
646
+ <div class="empty" v-if="!available">this record has no predictions table yet; a finished run or kalfa predict writes one</div>
647
+ <template v-else>
648
+ <div class="toolbar">
649
+ <input class="filter mono" type="search" v-model="where" placeholder="where, a pandas query" spellcheck="false"
650
+ @keyup.enter="apply">
651
+ <button type="button" class="tool" :disabled="loading" @click="apply">apply</button>
652
+ <button type="button" class="tool" v-if="applied" @click="clear">clear</button>
653
+ <select class="filter pred-select" v-if="manyTables" v-model="name">
654
+ <option v-for="table in tables" :key="table" :value="table">{{ table }}</option>
655
+ </select>
656
+ <span class="muted small">{{ rowsText }}</span>
657
+ <span class="muted small" v-if="loading && stats">updating</span>
658
+ </div>
659
+ <div class="error-box" v-if="error">{{ error }}</div>
660
+ <div class="loading" v-if="loading && !stats">scoring the predictions</div>
661
+ <div class="empty" v-if="noPairs">{{ name }} has no pred_ column</div>
662
+ <section class="card pred-overview" v-if="manyPairs">
663
+ <div class="card-head"><span class="card-title">overview</span><span class="muted small">{{ overviewNote }}</span></div>
664
+ <div class="table-wrap">
665
+ <table class="data">
666
+ <thead>
667
+ <tr><th>output</th><th>prediction</th><th>target</th><th>task</th><th>measure</th><th class="num">value</th><th class="num">rows</th></tr>
668
+ </thead>
669
+ <tbody>
670
+ <tr v-for="row in overviewRows" :key="row.pred" :class="{ selected: pair && row.pred === pair.pred }"
671
+ @click="pick(row.pred)">
672
+ <td class="mono">{{ row.output }}</td>
673
+ <td class="mono">{{ row.pred }}</td>
674
+ <td class="mono">{{ row.target || 'none' }}</td>
675
+ <td><span class="pred-task" :class="'is-' + row.task">{{ row.task }}</span></td>
676
+ <td class="muted">{{ row.key }}</td>
677
+ <td class="num mono">{{ valueText(row) }}</td>
678
+ <td class="num">{{ integer(row.count) }}</td>
679
+ </tr>
680
+ </tbody>
681
+ </table>
682
+ </div>
683
+ </section>
684
+ <div class="toolbar pred-pairs" v-if="manyPairs">
685
+ <button type="button" class="tool" v-for="item in pairs" :key="item.pred" :class="{ on: pair && pair.pred === item.pred }"
686
+ @click="pick(item.pred)">{{ pairLabel(item) }}</button>
687
+ </div>
688
+ <template v-if="pair">
689
+ <h3 class="section-title">{{ pair.output }} <span class="pred-task" :class="'is-' + pair.task">{{ pair.task }}</span>
690
+ <span class="muted small">{{ pairNote }}</span></h3>
691
+ <div class="muted small">{{ reason }}. {{ taskNote }}.</div>
692
+ <div class="pred-controls">
693
+ <label class="pred-control">
694
+ <span class="muted small">task</span>
695
+ <select class="filter pred-select" :value="taskChoice" @change="setTask($event.target.value)">
696
+ <option v-for="option in taskOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
697
+ </select>
698
+ </label>
699
+ <template v-if="isBinary">
700
+ <label class="pred-control" v-if="scoreOptions.length">
701
+ <span class="muted small">score</span>
702
+ <select class="filter pred-select" :value="numbers.score" @change="setScore($event.target.value)">
703
+ <option v-for="column in scoreOptions" :key="column" :value="column">{{ column }}</option>
704
+ </select>
705
+ </label>
706
+ <label class="pred-control">
707
+ <span class="muted small">positive</span>
708
+ <select class="filter pred-select" :value="pair.positive" @change="setPositive($event.target.value)">
709
+ <option v-for="label in positiveOptions" :key="label" :value="label">{{ label }}</option>
710
+ </select>
711
+ </label>
712
+ <label class="pred-control pred-threshold" v-if="numbers.score && slider !== null">
713
+ <span class="muted small">threshold</span>
714
+ <input type="range" :min="range.min" :max="range.max" :step="range.step" :value="slider"
715
+ @input="slide($event.target.value)">
716
+ <span class="mono">{{ sliderText }}</span>
717
+ <span class="muted small">{{ ruleText }}</span>
718
+ </label>
719
+ <button type="button" class="tool" v-if="choice.threshold !== undefined" @click="resetThreshold">default threshold</button>
720
+ <span class="muted small" v-if="!numbers.score">no score column, so the predicted labels are used and there are no curves</span>
721
+ </template>
722
+ <button type="button" class="tool" v-if="chosen" @click="resetChoice">back to auto</button>
723
+ </div>
724
+ <div class="cards">
725
+ <section class="card" v-for="item in metrics" :key="item[0]">
726
+ <div class="stat-label">{{ item[0] }}</div>
727
+ <div class="stat-value mono">{{ item[1] }}</div>
728
+ </section>
729
+ </div>
730
+ <template v-if="isRegression">
731
+ <div class="grid">
732
+ <k-chart v-if="scatterData" title="truth against prediction" kind="scatter" :data="scatterData"
733
+ :options="scatterOptions" :height="360" :log-toggle="false" :settings-key="chartKey('scatter')"></k-chart>
734
+ <k-chart v-if="residualData" title="residuals" kind="histogram" :data="residualData" :options="residualOptions"
735
+ :height="360" :settings-key="chartKey('residuals')" @settings="onSettings"></k-chart>
736
+ <k-chart v-if="histogramData" title="truth and prediction histograms" kind="histogram" :data="histogramData"
737
+ :options="histogramOptions" :height="360" :settings-key="chartKey('histograms')" @settings="onSettings"></k-chart>
738
+ </div>
739
+ <div class="grid">
740
+ <section class="card">
741
+ <div class="card-head"><span class="card-title">residual quantiles</span><span class="muted small">prediction minus truth</span></div>
742
+ <div class="table-wrap">
743
+ <table class="data">
744
+ <tbody>
745
+ <tr v-for="item in quantiles" :key="item.name"><td class="muted">{{ item.name }}</td><td class="num mono">{{ item.value }}</td></tr>
746
+ </tbody>
747
+ </table>
748
+ </div>
749
+ </section>
750
+ <section class="card" v-if="worst.length">
751
+ <div class="card-head"><span class="card-title">worst rows</span><span class="muted small">by absolute error</span></div>
752
+ <k-table :columns="worstColumns" :rows="worst" row-key="row" :filterable="false" empty="no rows"></k-table>
753
+ </section>
754
+ </div>
755
+ </template>
756
+ <template v-if="isBinary || isMulticlass">
757
+ <div class="toolbar">
758
+ <span class="muted small">confusion matrix</span>
759
+ <button type="button" class="tool" :class="{ on: matrix === 'counts' }" @click="setMatrix('counts')">counts</button>
760
+ <button type="button" class="tool" :class="{ on: matrix === 'rates' }" @click="setMatrix('rates')">rates</button>
761
+ <template v-if="isMulticlass">
762
+ <span class="muted small pred-gap">per class bars</span>
763
+ <button type="button" class="tool" :class="{ on: metric === 'precision' }" @click="setMetric('precision')">precision</button>
764
+ <button type="button" class="tool" :class="{ on: metric === 'recall' }" @click="setMetric('recall')">recall</button>
765
+ <button type="button" class="tool" :class="{ on: metric === 'f1' }" @click="setMetric('f1')">f1</button>
766
+ </template>
767
+ </div>
768
+ <div class="grid">
769
+ <k-chart v-if="confusionData" :title="confusionTitle" kind="heatmap" :data="confusionData" :options="confusionOptions"
770
+ :height="360" :log-toggle="false" :settings-key="chartKey('confusion')"></k-chart>
771
+ <template v-if="isBinary">
772
+ <k-chart v-if="rocData" :title="rocTitle" kind="lines" :data="rocData" :options="rocOptions" :height="360"
773
+ :log-toggle="false" :settings-key="chartKey('roc')"></k-chart>
774
+ <k-chart v-if="prData" :title="prTitle" kind="lines" :data="prData" :options="prOptions" :height="360"
775
+ :log-toggle="false" :settings-key="chartKey('pr')"></k-chart>
776
+ <k-chart v-if="scoreData" title="score by true class" kind="lines" :data="scoreData" :options="scoreChartOptions"
777
+ :height="360" :settings-key="chartKey('scores')" bins-box @settings="onSettings"></k-chart>
778
+ <k-chart v-if="sweepData" title="threshold sweep" kind="lines" :data="sweepData" :options="sweepOptions"
779
+ :height="360" :log-toggle="false" :settings-key="chartKey('sweep')"></k-chart>
780
+ </template>
781
+ <template v-if="isMulticlass">
782
+ <k-chart v-if="classBars" :title="metric + ' per class'" kind="bars" :data="classBars" :options="classBarsOptions"
783
+ :height="classBarsHeight" :log-toggle="false" :settings-key="chartKey('classes')"></k-chart>
784
+ <k-chart v-if="multiRocData" title="ROC, one class against the rest" kind="lines" :data="multiRocData"
785
+ :options="multiRocOptions" :height="360" :log-toggle="false" :settings-key="chartKey('roc')"></k-chart>
786
+ </template>
787
+ </div>
788
+ <section class="card">
789
+ <div class="card-head"><span class="card-title">per class table</span><span class="muted small">support counts the true rows</span></div>
790
+ <k-table :columns="classColumns" :rows="perClass" row-key="label" :filterable="false" empty="no classes"></k-table>
791
+ </section>
792
+ </template>
793
+ <template v-if="isEmbedding">
794
+ <div class="grid">
795
+ <k-chart v-if="pcaData" :title="pcaTitle" kind="scatter" :data="pcaData" :options="pcaOptions" :height="420"
796
+ :log-toggle="false" :settings-key="chartKey('pca')"></k-chart>
797
+ <k-chart v-if="normData" title="vector length" kind="histogram" :data="normData" :options="normOptions"
798
+ :height="420" :settings-key="chartKey('norms')" @settings="onSettings"></k-chart>
799
+ </div>
800
+ </template>
801
+ <template v-if="isValues">
802
+ <div class="grid">
803
+ <k-chart v-if="valuesData" title="distribution" kind="histogram" :data="valuesData" :options="valuesOptions"
804
+ :height="360" :settings-key="chartKey('values')" @settings="onSettings"></k-chart>
805
+ <k-chart v-if="labelBars" title="predicted labels" kind="bars" :data="labelBars" :options="labelBarsOptions"
806
+ :height="labelBarsHeight" :log-toggle="false" :settings-key="chartKey('labels')"></k-chart>
807
+ <section class="card" v-if="valueQuantiles.length">
808
+ <div class="card-head"><span class="card-title">quantiles</span></div>
809
+ <div class="table-wrap">
810
+ <table class="data">
811
+ <tbody>
812
+ <tr v-for="item in valueQuantiles" :key="item.name"><td class="muted">{{ item.name }}</td><td class="num mono">{{ item.value }}</td></tr>
813
+ </tbody>
814
+ </table>
815
+ </div>
816
+ </section>
817
+ </div>
818
+ </template>
819
+ <section class="card" v-if="gallery.length">
820
+ <div class="card-head"><span class="card-title">images</span><span class="muted small">{{ gallery.length }} rows, {{ galleryNote }}</span></div>
821
+ <div class="pred-gallery">
822
+ <figure class="pred-shot" v-for="entry in gallery" :key="entry.index" :class="{ wrong: entryWrong(entry) }">
823
+ <img :src="imageUrl(entry)" loading="lazy" :alt="entryText(entry)">
824
+ <figcaption>
825
+ <span class="mono">{{ entryText(entry) }}</span>
826
+ <span class="muted">{{ entryDetail(entry) }}</span>
827
+ </figcaption>
828
+ </figure>
829
+ </div>
830
+ </section>
831
+ </template>
832
+ </template>
833
+ </div>`,
834
+ };