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,56 @@
1
+ from kalfa.records import reader
2
+ from kalfa.std.plots.common import LEGEND_LIMIT, color_of, directory_of, dress, quiet, spans_decades
3
+
4
+
5
+ DEFAULT_PREFIXES = ("train/", "val/")
6
+
7
+
8
+ def turn_word(directory):
9
+ manifest = quiet(reader.read_manifest, directory) or {}
10
+ return "epoch" if manifest.get("unit") == "epochs" else "turn"
11
+
12
+
13
+ def history_series(history, names=None):
14
+ found = {}
15
+ for position, line in enumerate(history):
16
+ turn = reader.turn_of(line, position)
17
+ for key, value in line.items():
18
+ if not isinstance(key, str) or not reader.is_number(value):
19
+ continue
20
+ if names is None and not key.startswith(DEFAULT_PREFIXES):
21
+ continue
22
+ if names is not None and key not in names:
23
+ continue
24
+ turns, values = found.setdefault(key, ([], []))
25
+ turns.append(turn)
26
+ values.append(value)
27
+ if names is not None:
28
+ return {name: found[name] for name in names if name in found}
29
+ return found
30
+
31
+
32
+ def loss_curve(record, figures, series=None):
33
+ from matplotlib.ticker import MaxNLocator
34
+
35
+ directory = directory_of(record)
36
+ history = reader.read_history(directory)
37
+ names = None if series is None else ([series] if isinstance(series, str) else [str(name) for name in series])
38
+ found = history_series(history, names)
39
+ if not found:
40
+ return None
41
+ figure = figures.new(8.0, 5.0)
42
+ axis = figure.subplots()
43
+ for position, (name, (turns, values)) in enumerate(found.items()):
44
+ axis.plot(turns, values, label=name, color=color_of(figures, position), linewidth=1.8)
45
+ if spans_decades([value for _, values in found.values() for value in values]):
46
+ axis.set_yscale("log")
47
+ if len(found) <= LEGEND_LIMIT:
48
+ axis.legend(loc="upper right", frameon=False, ncols=1 if len(found) < 6 else 2)
49
+ word = turn_word(directory)
50
+ axis.xaxis.set_major_locator(MaxNLocator(integer=True))
51
+ axis.set_title(f"Training history ({len(history)} {word}s, {len(found)} series)", loc="left")
52
+ axis.set_xlabel(word)
53
+ axis.set_ylabel("value")
54
+ dress(axis)
55
+ figures.save(figure, "loss_curve")
56
+ return None
@@ -0,0 +1,188 @@
1
+ import math
2
+
3
+ import numpy
4
+
5
+ from kalfa.core.errors import KalfaError
6
+ from kalfa.std.plots.common import directory_of
7
+
8
+
9
+ TEXT_LINES = 20
10
+ TEXT_WIDTH = 120
11
+
12
+
13
+ def first_output(outputs):
14
+ import torch
15
+
16
+ if isinstance(outputs, torch.Tensor):
17
+ return "output", outputs
18
+ items = outputs.items() if isinstance(outputs, dict) else enumerate(outputs or ())
19
+ for name, value in items:
20
+ if isinstance(value, torch.Tensor):
21
+ return str(name), value
22
+ return None, None
23
+
24
+
25
+ def image_field(ctx, batch):
26
+ import torch
27
+
28
+ for name in getattr(getattr(ctx, "data", None), "inputs", None) or ():
29
+ if isinstance(batch.get(name), torch.Tensor):
30
+ return str(name)
31
+ for name, value in batch.items():
32
+ if isinstance(value, torch.Tensor) and value.ndim in (3, 4):
33
+ return str(name)
34
+ return None
35
+
36
+
37
+ def shaped(output, images, name):
38
+ if output.shape[1:] == images.shape[1:]:
39
+ return output
40
+ if output.ndim >= 2 and len(output) == len(images) and output[0].numel() == images[0].numel():
41
+ return output.reshape(images.shape)
42
+ if output.ndim in (3, 4):
43
+ return output
44
+ raise KalfaError(f"image_pairs: the output {name!r} of shape {tuple(output.shape)} cannot be drawn like the "
45
+ f"inputs of shape {tuple(images.shape)}")
46
+
47
+
48
+ def pixels(images):
49
+ images = images.detach().float().cpu()
50
+ if images.ndim == 4:
51
+ if images.shape[1] not in (1, 3):
52
+ images = images[:, :1]
53
+ images = images.permute(0, 2, 3, 1)
54
+ if images.shape[-1] == 1:
55
+ images = images[..., 0]
56
+ return images.numpy()
57
+
58
+
59
+ def show(axis, image, low, high):
60
+ scaled = numpy.clip((image - low) / (high - low), 0.0, 1.0) if high > low else numpy.zeros_like(image)
61
+ if scaled.ndim == 2:
62
+ axis.imshow(scaled, cmap="gray", vmin=0.0, vmax=1.0, interpolation="nearest")
63
+ else:
64
+ axis.imshow(scaled, interpolation="nearest")
65
+ axis.set_xticks([])
66
+ axis.set_yticks([])
67
+
68
+
69
+ def image_pairs(record, figures, n=8, set="test", ctx=None):
70
+ import torch
71
+
72
+ if ctx is None:
73
+ raise KalfaError("image_pairs needs the run; redraw it with the record open")
74
+ loaders = ctx.loaders()
75
+ loader = loaders.get(set)
76
+ if loader is None:
77
+ raise KalfaError(f"image_pairs: the data has no {set!r} set; its sets are {sorted(loaders)}")
78
+ modes = getattr(ctx, "set_modes", None)
79
+ if callable(modes):
80
+ modes(False)
81
+ device = getattr(ctx, "device", None)
82
+ count = max(int(n), 1)
83
+ field = name = None
84
+ shown, guessed = [], []
85
+ with torch.no_grad():
86
+ for batch in loader:
87
+ if device is not None:
88
+ batch = device.move(batch)
89
+ if not isinstance(batch, dict):
90
+ raise KalfaError("image_pairs needs batches that map field names to tensors")
91
+ field = field or image_field(ctx, batch)
92
+ if field is None:
93
+ raise KalfaError(f"image_pairs: none of the batch fields {sorted(batch)} holds images")
94
+ images = batch[field]
95
+ if images.ndim not in (3, 4):
96
+ raise KalfaError(f"image_pairs: the input {field!r} of shape {tuple(images.shape)} is not a batch of "
97
+ f"images")
98
+ name, output = first_output(ctx.step.predict(batch, ctx))
99
+ if output is None:
100
+ raise KalfaError("image_pairs: the run predicts no tensor; train.predicts names the model to draw")
101
+ shown.append(images.detach().float().cpu())
102
+ guessed.append(shaped(output, images, name).detach().float().cpu())
103
+ if sum(len(piece) for piece in shown) >= count:
104
+ break
105
+ if not shown:
106
+ return None
107
+ inputs = pixels(torch.cat(shown)[:count])
108
+ outputs = pixels(torch.cat(guessed)[:count])
109
+ total = min(len(inputs), len(outputs))
110
+ values = numpy.concatenate([inputs[:total].reshape(-1), outputs[:total].reshape(-1)])
111
+ values = values[numpy.isfinite(values)]
112
+ low, high = (float(values.min()), float(values.max())) if values.size else (0.0, 1.0)
113
+ figure = figures.new(max(1.3 * total + 0.8, 3.2), 3.2)
114
+ axes = figure.subplots(2, total, squeeze=False)
115
+ for row, (images, label) in enumerate(((inputs, field), (outputs, name))):
116
+ for column in range(total):
117
+ show(axes[row][column], images[column], low, high)
118
+ axes[row][0].set_ylabel(label)
119
+ figure.suptitle(f"{field} and {name} ({set}, n = {total})", x=0.02, ha="left")
120
+ figures.save(figure, "image_pairs")
121
+ return None
122
+
123
+
124
+ def sample_images(samples, path):
125
+ import torch
126
+
127
+ if isinstance(samples, (list, tuple)) and samples and all(isinstance(item, torch.Tensor) for item in samples):
128
+ try:
129
+ samples = torch.stack([item.detach().cpu() for item in samples])
130
+ except RuntimeError as error:
131
+ raise KalfaError(f"samples_grid: the tensors in {path.name} differ in shape: {error}") from None
132
+ if not isinstance(samples, torch.Tensor) or samples.ndim not in (3, 4) or not len(samples):
133
+ if isinstance(samples, torch.Tensor):
134
+ kind = f"a tensor of shape {tuple(samples.shape)}"
135
+ else:
136
+ kind = f"a {type(samples).__name__}"
137
+ raise KalfaError(f"samples_grid: {path.name} holds {kind}, not a batch of images (N, C, H, W)")
138
+ images = samples.detach().float().cpu()
139
+ if images.ndim == 4 and images.shape[1] not in (1, 3):
140
+ images = images[:, :1]
141
+ return images
142
+
143
+
144
+ def text_sheet(path, figures, lines=None):
145
+ if lines is None:
146
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
147
+ shown = [line.expandtabs(4) for line in lines[:TEXT_LINES]]
148
+ shown = [line if len(line) <= TEXT_WIDTH else line[:TEXT_WIDTH - 3] + "..." for line in shown]
149
+ figure = figures.new(10.0, 0.8 + 0.22 * max(len(shown), 1))
150
+ axis = figure.subplots()
151
+ axis.axis("off")
152
+ axis.text(0.0, 1.0, "\n".join(shown) or "(empty)", family="monospace", fontsize=9, va="top", ha="left",
153
+ transform=axis.transAxes, parse_math=False)
154
+ axis.set_title(f"{path.name} (first {len(shown)} of {len(lines)} lines)", loc="left")
155
+ figures.save(figure, "samples_grid")
156
+ return None
157
+
158
+
159
+ def samples_grid(record, figures, n=8, file="samples"):
160
+ import torch
161
+
162
+ from kalfa.std.samples import image_grid, is_text
163
+
164
+ directory = directory_of(record) / "samples"
165
+ stem = str(file)
166
+ names = [stem] if stem.endswith((".pt", ".txt")) else [f"{stem}.pt", f"{stem}.txt"]
167
+ path = next((directory / name for name in names if (directory / name).is_file()), None)
168
+ if path is None:
169
+ wanted = " or ".join(f"samples/{name}" for name in names)
170
+ raise KalfaError(f"samples_grid: {wanted} is missing in {directory.parent}; a sampler under generate or a "
171
+ f"write_samples rule writes it")
172
+ if path.suffix == ".txt":
173
+ return text_sheet(path, figures)
174
+ loaded = torch.load(path, map_location="cpu", weights_only=False)
175
+ if is_text(loaded):
176
+ return text_sheet(path, figures, "\n\n".join(loaded).splitlines())
177
+ images = sample_images(loaded, path)
178
+ count = min(len(images), max(int(n), 1))
179
+ grid = image_grid(images[:count], columns=math.ceil(math.sqrt(count)))
180
+ height, width = grid.shape[:2]
181
+ scale = 8.0 / max(height, width)
182
+ figure = figures.new(max(width * scale + 0.4, 3.0), height * scale + 0.8)
183
+ axis = figure.subplots()
184
+ axis.imshow(grid, interpolation="nearest")
185
+ axis.axis("off")
186
+ axis.set_title(f"{path.name} ({count} of {len(images)} samples)", loc="left")
187
+ figures.save(figure, "samples_grid")
188
+ return None
@@ -0,0 +1,66 @@
1
+ from kalfa.records import stats
2
+
3
+
4
+ def read_predictions(directory):
5
+ frame = stats.read_predictions(directory)
6
+ return frame if frame is not None and len(frame) else None
7
+
8
+
9
+ def pred_column(output):
10
+ return output if str(output).startswith("pred_") else f"pred_{output}"
11
+
12
+
13
+ def prediction_pairs(frame, plan, pairs=None):
14
+ columns = [str(column) for column in frame.columns]
15
+ if pairs:
16
+ chosen = [(pred_column(output), str(target)) for output, target in dict(pairs).items()]
17
+ return [(pred, target) for pred, target in chosen if pred in columns and target in columns]
18
+ found = [(pair["pred"], pair["target"]) for pair in stats.prediction_pairs(frame, plan)]
19
+ paired = {pred for pred, _ in found}
20
+ preds = [column for column in columns if column.startswith("pred_") and column not in paired
21
+ and stats.spread_member(column, columns) is None]
22
+ targets = [name for name in stats.plan_targets(plan) if name in columns]
23
+ if not targets:
24
+ targets = [column for column in columns
25
+ if not column.startswith(("pred_", "raw_", "prob_")) and column not in ("row", "set")]
26
+ if len(targets) == 1:
27
+ found.extend((pred, targets[0]) for pred in preds)
28
+ return found
29
+ for pred in preds:
30
+ output = pred[len("pred_"):]
31
+ matches = [target for target in targets if output == target or output.endswith(f"_{target}")]
32
+ if matches:
33
+ found.append((pred, max(matches, key=len)))
34
+ return found
35
+
36
+
37
+ def pick_pair(frame, plan, pred=None, target=None):
38
+ column = None if pred is None else pred_column(pred)
39
+ if column is not None and target is not None:
40
+ columns = [str(name) for name in frame.columns]
41
+ return (column, str(target)) if column in columns and str(target) in columns else None
42
+ for found_pred, found_target in prediction_pairs(frame, plan):
43
+ if column is not None and found_pred != column:
44
+ continue
45
+ if target is not None and found_target != str(target):
46
+ continue
47
+ return found_pred, found_target
48
+ return None
49
+
50
+
51
+ def chosen_pair(frame, plan, fitted, pred=None, target=None, prefer=None):
52
+ columns = [str(column) for column in frame.columns]
53
+ column = None if pred is None else pred_column(pred)
54
+ if column is not None and target is not None:
55
+ target = str(target)
56
+ if column not in columns or target not in columns:
57
+ return None
58
+ output = column[len("pred_"):]
59
+ scores = [name for name in columns if name.startswith(f"prob_{output}_")]
60
+ raw = f"raw_{output}" if f"raw_{output}" in columns else None
61
+ kind = stats.kind_of(frame, target, stats.classes_for(plan, fitted, target), scores)
62
+ return {"output": output, "pred": column, "target": target, "kind": kind, "raw": raw, "scores": scores}
63
+ found = [pair for pair in stats.prediction_pairs(frame, plan, fitted)
64
+ if (column is None or pair["pred"] == column) and (target is None or pair["target"] == str(target))]
65
+ preferred = [pair for pair in found if pair["kind"] == prefer]
66
+ return (preferred or found or [None])[0]
@@ -0,0 +1,154 @@
1
+ import re
2
+
3
+ import numpy
4
+
5
+ from kalfa.records import reader, stats
6
+ from kalfa.std.plots.common import color_of, directory_of, dress, label_text, quiet
7
+ from kalfa.std.plots.pairs import chosen_pair, pred_column, prediction_pairs, read_predictions
8
+
9
+
10
+ HORIZON = re.compile(r"(.+)_(\d+)")
11
+
12
+
13
+ def numeric(frame, column):
14
+ return frame[column].dtype.kind in "fiu"
15
+
16
+
17
+ def r_squared(truth, guess):
18
+ spread = float(numpy.sum((truth - truth.mean()) ** 2))
19
+ if spread <= 0:
20
+ return None
21
+ return 1.0 - float(numpy.sum((guess - truth) ** 2)) / spread
22
+
23
+
24
+ def pred_vs_true(record, figures, pairs=None):
25
+ directory = directory_of(record)
26
+ frame = read_predictions(directory)
27
+ if frame is None:
28
+ return None
29
+ chosen = [(pred, target) for pred, target in prediction_pairs(frame, quiet(reader.read_plan, directory), pairs)
30
+ if numeric(frame, pred) and numeric(frame, target)]
31
+ if not chosen:
32
+ return None
33
+ columns = min(3, len(chosen))
34
+ rows = -(-len(chosen) // columns)
35
+ figure = figures.new(4.8 * columns, 4.4 * rows)
36
+ panels = [axis for row in figure.subplots(rows, columns, squeeze=False) for axis in row]
37
+ for axis, (pred, target) in zip(panels, chosen):
38
+ truth = frame[target].to_numpy(dtype="float64", na_value=numpy.nan)
39
+ guess = frame[pred].to_numpy(dtype="float64", na_value=numpy.nan)
40
+ keep = numpy.isfinite(truth) & numpy.isfinite(guess)
41
+ truth, guess = truth[keep], guess[keep]
42
+ if not len(truth):
43
+ axis.axis("off")
44
+ continue
45
+ axis.scatter(truth, guess, s=12, alpha=0.5, color=color_of(figures, 0), edgecolors="none", rasterized=True)
46
+ low = float(min(truth.min(), guess.min()))
47
+ high = float(max(truth.max(), guess.max()))
48
+ axis.plot([low, high], [low, high], color=color_of(figures, 1), linewidth=1.5, label="y = x")
49
+ axis.legend(loc="upper left", frameon=False)
50
+ score = r_squared(truth, guess)
51
+ note = f"R² {score:.3f}, " if score is not None else ""
52
+ axis.set_title(f"{target} ({note}n = {len(truth):,})", loc="left")
53
+ axis.set_xlabel(f"true {target}")
54
+ axis.set_ylabel(f"predicted ({pred[len('pred_'):]})")
55
+ dress(axis)
56
+ for axis in panels[len(chosen):]:
57
+ axis.axis("off")
58
+ figures.save(figure, "pred_vs_true")
59
+ return None
60
+
61
+
62
+ def indexed(columns, name):
63
+ found = {}
64
+ for column in columns:
65
+ match = HORIZON.fullmatch(column)
66
+ if match is not None and match.group(1) == name:
67
+ found[int(match.group(2))] = column
68
+ return found
69
+
70
+
71
+ def horizon_pair(frame, plan, pred=None, target=None):
72
+ columns = [str(column) for column in frame.columns]
73
+ stems = []
74
+ for column in columns:
75
+ match = HORIZON.fullmatch(column)
76
+ if match is not None and match.group(1) not in stems:
77
+ stems.append(match.group(1))
78
+ if pred is not None:
79
+ outputs = [pred_column(pred)[len("pred_"):]]
80
+ else:
81
+ outputs = [stem[len("pred_"):] for stem in stems if stem.startswith("pred_")]
82
+ known = [stem for stem in stems if not stem.startswith(("pred_", "raw_", "prob_"))]
83
+ pairings = stats.plan_pairings(plan)
84
+ for output in outputs:
85
+ guesses = indexed(columns, f"pred_{output}")
86
+ names = [str(target)] if target is not None else [pairings.get(output), *stats.plan_targets(plan), *known]
87
+ for name in names:
88
+ truths = indexed(columns, name) if name else {}
89
+ steps = sorted(step for step in guesses if step in truths)
90
+ if steps:
91
+ return output, name, [truths[step] for step in steps], [guesses[step] for step in steps]
92
+ return None
93
+
94
+
95
+ def forecast_series(frame, plan, fitted, figures, pred, target):
96
+ pair = chosen_pair(frame, plan, fitted, pred, target, prefer="regression")
97
+ if pair is None or not numeric(frame, pair["pred"]) or not numeric(frame, pair["target"]):
98
+ return None
99
+ rows = frame["row"].to_numpy() if "row" in frame.columns else numpy.arange(len(frame))
100
+ truth = frame[pair["target"]].to_numpy(dtype="float64", na_value=numpy.nan)
101
+ guess = frame[pair["pred"]].to_numpy(dtype="float64", na_value=numpy.nan)
102
+ figure = figures.new(9.0, 4.2)
103
+ axis = figure.subplots()
104
+ axis.plot(rows, truth, color=color_of(figures, 0), linewidth=1.4, label="true")
105
+ axis.plot(rows, guess, color=color_of(figures, 1), linewidth=1.4, linestyle="--", label="predicted")
106
+ axis.legend(loc="upper right", frameon=False)
107
+ axis.set_title(f"{pair['target']} over rows (n = {len(frame):,})", loc="left")
108
+ axis.set_xlabel("row")
109
+ axis.set_ylabel(f"{pair['target']} and {pair['output']}")
110
+ dress(axis)
111
+ figures.save(figure, "forecast_samples")
112
+ return None
113
+
114
+
115
+ def forecast_samples(record, figures, n=4, pred=None, target=None):
116
+ from matplotlib.ticker import MaxNLocator
117
+
118
+ directory = directory_of(record)
119
+ frame = stats.read_predictions(directory)
120
+ if frame is None or not len(frame):
121
+ return None
122
+ plan = quiet(reader.read_plan, directory)
123
+ if "row" in frame.columns:
124
+ frame = frame.sort_values("row", kind="stable")
125
+ found = horizon_pair(frame, plan, pred, target)
126
+ if found is None:
127
+ return forecast_series(frame, plan, quiet(reader.fields_of, directory), figures, pred, target)
128
+ output, name, truths, guesses = found
129
+ picked = numpy.unique(numpy.linspace(0, len(frame) - 1, min(max(int(n), 1), len(frame))).round().astype(int))
130
+ columns = 1 if len(picked) == 1 else 2
131
+ rows = -(-len(picked) // columns)
132
+ figure = figures.new(5.2 * columns, 2.4 * rows + 0.8)
133
+ panels = [axis for line in figure.subplots(rows, columns, squeeze=False) for axis in line]
134
+ steps = numpy.arange(1, len(truths) + 1)
135
+ truth = frame[truths].to_numpy(dtype="float64", na_value=numpy.nan)
136
+ guess = frame[guesses].to_numpy(dtype="float64", na_value=numpy.nan)
137
+ labels = frame["row"].to_numpy() if "row" in frame.columns else frame.index.to_numpy()
138
+ for index, (axis, position) in enumerate(zip(panels, picked)):
139
+ axis.plot(steps, truth[position], color=color_of(figures, 0), linewidth=1.8, marker="o", markersize=3,
140
+ label="true")
141
+ axis.plot(steps, guess[position], color=color_of(figures, 1), linewidth=1.8, linestyle="--",
142
+ label="predicted")
143
+ axis.set_title(f"row {label_text(labels[position])}", loc="left")
144
+ axis.xaxis.set_major_locator(MaxNLocator(integer=True))
145
+ if index + columns >= len(picked):
146
+ axis.set_xlabel("steps ahead")
147
+ dress(axis)
148
+ for axis in panels[len(picked):]:
149
+ axis.axis("off")
150
+ panels[0].legend(loc="upper left", frameon=False)
151
+ figure.suptitle(f"Forecast windows ({name} against {output}, {len(steps)} steps, {len(picked)} of "
152
+ f"{len(frame):,} rows)", x=0.02, ha="left")
153
+ figures.save(figure, "forecast_samples")
154
+ return None