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,360 @@
1
+ import copy
2
+ import math
3
+
4
+ import torch
5
+ from torch import nn
6
+ from torch.nn import functional
7
+
8
+ from kalfa.core.aliases import register
9
+ from kalfa.core.errors import RunError
10
+ from kalfa.core.predict import batch_size, target_names
11
+ from kalfa.core.protocols import Objective
12
+ from kalfa.core.rng import Rng
13
+ from kalfa.core.wrap import loss_scalar
14
+
15
+
16
+ NOISE = "noise"
17
+
18
+
19
+ def title(owner):
20
+ name = getattr(owner, "name", "")
21
+ return f"loss {name!r}" if name else type(owner).__name__
22
+
23
+
24
+ def named(obj):
25
+ return getattr(obj, "name", "") or getattr(obj, "__name__", "") or type(obj).__name__
26
+
27
+
28
+ def field(batch, name, owner):
29
+ if name not in batch:
30
+ raise RunError(f"{title(owner)}: the batch has no field {name!r}; its fields are {sorted(map(str, batch))}")
31
+ return batch[name]
32
+
33
+
34
+ def first(result):
35
+ if isinstance(result, (tuple, list)):
36
+ return result[0]
37
+ if isinstance(result, dict):
38
+ return next(iter(result.values()))
39
+ return result
40
+
41
+
42
+ def call(module, value, label):
43
+ return module(value) if label is None else module(value, label)
44
+
45
+
46
+ def device_of(module, batch):
47
+ for parameter in module.parameters():
48
+ return parameter.device
49
+ for value in batch.values() if isinstance(batch, dict) else ():
50
+ if isinstance(value, torch.Tensor):
51
+ return value.device
52
+ return torch.device("cpu")
53
+
54
+
55
+ def normal(ctx, shape, like=None, device=None):
56
+ rng = getattr(ctx, "rng", None)
57
+ if rng is not None:
58
+ return rng.normal(NOISE, shape, like=like, device=device)
59
+ if like is None:
60
+ return torch.randn(shape, device=device)
61
+ return torch.randn(shape, device=like.device, dtype=like.dtype if like.dtype.is_floating_point else None)
62
+
63
+
64
+ def uniform(ctx, shape, like):
65
+ rng = getattr(ctx, "rng", None)
66
+ if rng is not None:
67
+ return rng.uniform(NOISE, shape, like=like)
68
+ return torch.rand(shape, device=like.device, dtype=like.dtype if like.dtype.is_floating_point else None)
69
+
70
+
71
+ def integers(ctx, high, count, device):
72
+ rng = getattr(ctx, "rng", None)
73
+ if rng is not None:
74
+ return rng.integers(NOISE, 0, high, (count,), device=device)
75
+ return torch.randint(0, int(high), (count,), device=device)
76
+
77
+
78
+ def owned_modules(ctx):
79
+ found = []
80
+ for entry in (getattr(ctx, "models", None) or {}).values():
81
+ for module in (getattr(entry, "module", None), getattr(entry, "ema", None)):
82
+ if isinstance(module, nn.Module):
83
+ found.extend(module.modules())
84
+ return found
85
+
86
+
87
+ def release(objective, ctx):
88
+ owned = owned_modules(ctx)
89
+ for name, module in list(objective.named_children()):
90
+ if any(module is part for part in owned):
91
+ delattr(objective, name)
92
+ object.__setattr__(objective, name, module)
93
+
94
+
95
+ def gaussian_kl(mu, logvar):
96
+ return -0.5 * torch.mean(torch.sum(1.0 + logvar - mu.pow(2) - logvar.exp(), dim=-1))
97
+
98
+
99
+ class BetaSchedule:
100
+ def __init__(self, steps):
101
+ self.steps = int(steps)
102
+ if self.steps < 1:
103
+ raise ValueError(f"a beta schedule needs at least one step, got {steps}")
104
+ self.betas = self.compute()
105
+
106
+ def compute(self):
107
+ raise NotImplementedError
108
+
109
+
110
+ class LinearBeta(BetaSchedule):
111
+ def __init__(self, steps, start=1.0e-4, end=0.02):
112
+ self.start = float(start)
113
+ self.end = float(end)
114
+ if not (0.0 < self.start < 1.0 and 0.0 < self.end < 1.0):
115
+ raise ValueError(f"linear_beta needs start and end inside (0, 1), got {start} and {end}")
116
+ super().__init__(steps)
117
+
118
+ def compute(self):
119
+ return torch.linspace(self.start, self.end, self.steps, dtype=torch.float64).to(torch.float32)
120
+
121
+
122
+ class CosineBeta(BetaSchedule):
123
+ def __init__(self, steps, s=0.008):
124
+ self.s = float(s)
125
+ super().__init__(steps)
126
+
127
+ def compute(self):
128
+ grid = torch.arange(self.steps + 1, dtype=torch.float64) / self.steps
129
+ curve = torch.cos((grid + self.s) / (1.0 + self.s) * math.pi / 2.0) ** 2
130
+ cumulative = curve / curve[0]
131
+ betas = 1.0 - cumulative[1:] / cumulative[:-1]
132
+ return betas.clamp(1.0e-8, 0.999).to(torch.float32)
133
+
134
+
135
+ class NoiseTables:
136
+ def __init__(self, schedule):
137
+ self.schedule = schedule
138
+ self.steps = schedule.steps
139
+ self.betas = schedule.betas
140
+ self.alphas = 1.0 - self.betas
141
+ self.cumulative = torch.cumprod(self.alphas, dim=0)
142
+ self.placed = {}
143
+
144
+ def on(self, device, dtype=torch.float32):
145
+ where = Rng.place(device)
146
+ key = (str(where), dtype)
147
+ found = self.placed.get(key)
148
+ if found is None:
149
+ found = copy.copy(self)
150
+ found.betas = self.betas.to(where, dtype)
151
+ found.alphas = self.alphas.to(where, dtype)
152
+ found.cumulative = self.cumulative.to(where, dtype)
153
+ self.placed[key] = found
154
+ return found
155
+
156
+ def __iter__(self):
157
+ return iter((self.betas, self.alphas, self.cumulative))
158
+
159
+
160
+ class VaeElbo(Objective):
161
+ def __init__(self, recon, w_rec=1.0, w_kl=1.0, mu="mu", logvar="logvar"):
162
+ super().__init__()
163
+ self.recon = recon
164
+ self.w_rec = float(w_rec)
165
+ self.w_kl = w_kl
166
+ self.mu = mu
167
+ self.logvar = logvar
168
+
169
+ def weight(self, ctx):
170
+ if callable(self.w_kl):
171
+ return float(self.w_kl(getattr(ctx, "updates", 0)))
172
+ return float(self.w_kl)
173
+
174
+ def forward(self, outputs, batch, ctx):
175
+ outputs = {} if outputs is None else outputs
176
+ missing = [name for name in (self.mu, self.logvar) if name not in outputs]
177
+ if missing:
178
+ raise RunError(f"{title(self)}: the step outputs have no {missing}; they are {sorted(map(str, outputs))}; "
179
+ "name the encoder outputs with mu and logvar")
180
+ kl = gaussian_kl(outputs[self.mu].float(), outputs[self.logvar].float())
181
+ recon = loss_scalar(self.recon(outputs, batch, ctx), named(self.recon))
182
+ w_kl = self.weight(ctx)
183
+ return {"loss": self.w_rec * recon + w_kl * kl, "recon": recon, "kl": kl, "w_kl": w_kl}
184
+
185
+
186
+ class WganGpD(Objective):
187
+ on = ("train",)
188
+
189
+ def __init__(self, generator, critic, latent, real="image", label=None, gp_weight=10.0):
190
+ super().__init__()
191
+ self.generator = generator
192
+ self.critic = critic
193
+ self.latent = int(latent)
194
+ self.real = real
195
+ self.label = label
196
+ self.gp_weight = float(gp_weight)
197
+
198
+ def bind(self, ctx):
199
+ release(self, ctx)
200
+
201
+ def forward(self, outputs, batch, ctx):
202
+ real = field(batch, self.real, self).detach()
203
+ label = None if self.label is None else field(batch, self.label, self)
204
+ count = real.shape[0]
205
+ z = normal(ctx, (count, self.latent), device=real.device)
206
+ with torch.no_grad():
207
+ fake = first(call(self.generator, z, label)).detach()
208
+ if fake.shape != real.shape:
209
+ raise RunError(f"{title(self)}: the generator makes {tuple(fake.shape)} but the real field "
210
+ f"{self.real!r} is {tuple(real.shape)}")
211
+ score_real = first(call(self.critic, real, label))
212
+ score_fake = first(call(self.critic, fake, label))
213
+ with torch.enable_grad():
214
+ mix = uniform(ctx, (count,) + (1,) * (real.ndim - 1), like=real)
215
+ between = (mix * real + (1.0 - mix) * fake).requires_grad_(True)
216
+ score = first(call(self.critic, between, label))
217
+ gradients = torch.autograd.grad(score.sum(), between, create_graph=True)[0]
218
+ penalty = ((gradients.flatten(1).norm(dim=1) - 1.0) ** 2).mean()
219
+ wasserstein = score_real.mean() - score_fake.mean()
220
+ return {"loss": -wasserstein + self.gp_weight * penalty, "wasserstein": wasserstein, "gp": penalty}
221
+
222
+
223
+ class WganG(Objective):
224
+ def __init__(self, generator, critic, latent, label=None):
225
+ super().__init__()
226
+ self.generator = generator
227
+ self.critic = critic
228
+ self.latent = int(latent)
229
+ self.label = label
230
+
231
+ def bind(self, ctx):
232
+ release(self, ctx)
233
+
234
+ def forward(self, outputs, batch, ctx):
235
+ label = None if self.label is None else field(batch, self.label, self)
236
+ z = normal(ctx, (batch_size(batch), self.latent), device=device_of(self.generator, batch))
237
+ fake = first(call(self.generator, z, label))
238
+ return -first(call(self.critic, fake, label)).mean()
239
+
240
+
241
+ class Ddpm(Objective):
242
+ def __init__(self, model, schedule, x="image"):
243
+ super().__init__()
244
+ self.model = model
245
+ self.schedule = schedule
246
+ self.x = x
247
+ self.tables = NoiseTables(schedule)
248
+
249
+ def bind(self, ctx):
250
+ release(self, ctx)
251
+
252
+ def forward(self, outputs, batch, ctx):
253
+ if self.tables.schedule is not self.schedule:
254
+ self.tables = NoiseTables(self.schedule)
255
+ x = field(batch, self.x, self)
256
+ tables = self.tables.on(x.device)
257
+ count = x.shape[0]
258
+ t = integers(ctx, tables.steps, count, x.device)
259
+ noise = normal(ctx, x.shape, like=x)
260
+ weight = tables.cumulative[t].to(x.dtype).reshape((count,) + (1,) * (x.ndim - 1))
261
+ noised = weight.sqrt() * x + (1.0 - weight).sqrt() * noise
262
+ predicted = first(self.model(noised, t))
263
+ return functional.mse_loss(predicted.float(), noise.float())
264
+
265
+
266
+ class Distill(Objective):
267
+ def __init__(self, student, teacher, x=None, label=None, temperature=1.0, alpha=0.5):
268
+ super().__init__()
269
+ self.student = student
270
+ self.teacher = teacher
271
+ self.x = x
272
+ self.label = label
273
+ self.temperature = float(temperature)
274
+ self.alpha = float(alpha)
275
+
276
+ def bind(self, ctx):
277
+ release(self, ctx)
278
+
279
+ def input_name(self, ctx):
280
+ if self.x is not None:
281
+ return self.x
282
+ inputs = list(getattr(getattr(ctx, "data", None), "inputs", None) or ())
283
+ if not inputs:
284
+ raise RunError(f"{title(self)}: x is not written and the data declares no inputs; write x: <field>")
285
+ return inputs[0]
286
+
287
+ def label_name(self, ctx):
288
+ if self.label is not None:
289
+ return self.label
290
+ names = target_names(getattr(ctx, "data", None))
291
+ if len(names) != 1:
292
+ raise RunError(f"{title(self)}: label is not written and the data has {len(names)} targets {names}; "
293
+ "write label: <field>")
294
+ return names[0]
295
+
296
+ def forward(self, outputs, batch, ctx):
297
+ x = field(batch, self.input_name(ctx), self)
298
+ labels = field(batch, self.label_name(ctx), self).reshape(-1).long()
299
+ logits = first(self.student(x)).float()
300
+ with torch.no_grad():
301
+ guide = first(self.teacher(x)).float()
302
+ scale = self.temperature
303
+ soft = functional.softmax(guide / scale, dim=-1)
304
+ guess = functional.log_softmax(logits / scale, dim=-1)
305
+ kl = functional.kl_div(guess, soft, reduction="batchmean") * scale * scale
306
+ ce = functional.cross_entropy(logits, labels)
307
+ return {"loss": self.alpha * kl + (1.0 - self.alpha) * ce, "ce": ce, "kl": kl}
308
+
309
+
310
+ class Ntxent(Objective):
311
+ def __init__(self, encoder, projector=None, views="image", temperature=0.5):
312
+ super().__init__()
313
+ self.encoder = encoder
314
+ self.projector = projector
315
+ self.views = views
316
+ self.temperature = float(temperature)
317
+
318
+ def bind(self, ctx):
319
+ release(self, ctx)
320
+
321
+ def forward(self, outputs, batch, ctx):
322
+ views = field(batch, self.views, self)
323
+ if views.ndim < 3 or views.shape[1] != 2:
324
+ raise RunError(f"{title(self)}: ntxent needs two views per item, a (B, 2, ...) field, but {self.views!r} "
325
+ f"is {tuple(views.shape)}; put two_views in the train transform")
326
+ count = views.shape[0]
327
+ embedded = first(self.encoder(views.flatten(0, 1)))
328
+ if self.projector is not None:
329
+ embedded = first(self.projector(embedded))
330
+ z = functional.normalize(embedded.flatten(1).float(), dim=1)
331
+ similarity = z @ z.t() / self.temperature
332
+ diagonal = torch.eye(2 * count, device=z.device, dtype=torch.bool)
333
+ similarity = similarity.masked_fill(diagonal, float("-inf"))
334
+ partner = torch.arange(2 * count, device=z.device) ^ 1
335
+ return functional.cross_entropy(similarity, partner)
336
+
337
+
338
+ REGISTRATIONS = (
339
+ ("vae_elbo", "kalfa.std.objectives.VaeElbo", "loss",
340
+ "w_rec times the recon criterion on the outputs plus w_kl (a number or a schedule of the update count) times the "
341
+ "gaussian KL of the mu and logvar outputs; terms recon, kl, w_kl"),
342
+ ("wgan_gp_d", "kalfa.std.objectives.WganGpD", "loss",
343
+ "the critic loss of WGAN GP: fake minus real scores plus gp_weight times the gradient penalty on interpolates; "
344
+ "the latent noise comes from the noise stream; terms wasserstein, gp; train only by default"),
345
+ ("wgan_g", "kalfa.std.objectives.WganG", "loss",
346
+ "the generator loss of WGAN: minus the critic score of fresh fakes"),
347
+ ("ddpm", "kalfa.std.objectives.Ddpm", "loss",
348
+ "the denoising loss: noise the x field at a random step of the beta schedule and regress the noise with "
349
+ "model(x_t, t)"),
350
+ ("distill", "kalfa.std.objectives.Distill", "loss",
351
+ "alpha times the KL between student and teacher logits at temperature plus (1 - alpha) times cross entropy with "
352
+ "the label field (default the only target of the data); terms ce, kl"),
353
+ ("ntxent", "kalfa.std.objectives.Ntxent", "loss",
354
+ "NT-Xent over two views per image: the encoder (and projector) embeds both views, partners are positives"),
355
+ ("linear_beta", "kalfa.std.objectives.LinearBeta", "value", "betas rising linearly from start to end over steps"),
356
+ ("cosine_beta", "kalfa.std.objectives.CosineBeta", "value", "the cosine beta schedule of Nichol and Dhariwal"),
357
+ )
358
+
359
+ for name, target, slot, doc in REGISTRATIONS:
360
+ register(name, target, slot=slot, doc=doc)
@@ -0,0 +1,75 @@
1
+ from kalfa.core.aliases import register
2
+ from kalfa.std.plots.classification import (
3
+ MAX_CLASSES,
4
+ SKEW,
5
+ binary_curve,
6
+ binary_precision_recall_curve,
7
+ binary_roc,
8
+ class_histogram,
9
+ classified,
10
+ confusion_matrix,
11
+ floats,
12
+ label_set,
13
+ score_column,
14
+ )
15
+ from kalfa.std.plots.common import (
16
+ GRID,
17
+ INK,
18
+ LEGEND_LIMIT,
19
+ MUTED,
20
+ color_of,
21
+ directory_of,
22
+ dress,
23
+ label_text,
24
+ quiet,
25
+ spans_decades,
26
+ )
27
+ from kalfa.std.plots.curves import DEFAULT_PREFIXES, history_series, loss_curve, turn_word
28
+ from kalfa.std.plots.images import (
29
+ TEXT_LINES,
30
+ TEXT_WIDTH,
31
+ first_output,
32
+ image_field,
33
+ image_pairs,
34
+ pixels,
35
+ sample_images,
36
+ samples_grid,
37
+ shaped,
38
+ show,
39
+ text_sheet,
40
+ )
41
+ from kalfa.std.plots.pairs import chosen_pair, pick_pair, pred_column, prediction_pairs, read_predictions
42
+ from kalfa.std.plots.predictions import (
43
+ HORIZON,
44
+ forecast_samples,
45
+ forecast_series,
46
+ horizon_pair,
47
+ indexed,
48
+ numeric,
49
+ pred_vs_true,
50
+ r_squared,
51
+ )
52
+
53
+
54
+ __all__ = ["DEFAULT_PREFIXES", "GRID", "HORIZON", "INK", "LEGEND_LIMIT", "MAX_CLASSES", "MUTED", "SKEW", "TEXT_LINES",
55
+ "TEXT_WIDTH", "binary_curve", "binary_precision_recall_curve", "binary_roc", "chosen_pair",
56
+ "class_histogram", "classified", "color_of", "confusion_matrix", "directory_of", "dress", "first_output",
57
+ "floats", "forecast_samples", "forecast_series", "history_series", "horizon_pair", "image_field",
58
+ "image_pairs", "indexed", "label_set", "label_text", "loss_curve", "numeric", "pick_pair", "pixels",
59
+ "pred_column", "pred_vs_true", "prediction_pairs", "quiet", "r_squared", "read_predictions",
60
+ "sample_images", "samples_grid", "score_column", "shaped", "show", "spans_decades", "text_sheet",
61
+ "turn_word"]
62
+
63
+ for _name, _doc in (
64
+ ("loss_curve", "history.jsonl series against the turn; series names the keys, default every train/ and val/ key"),
65
+ ("pred_vs_true", "scatter of each pred_<output> against its target with a y = x line; pairs maps output to target"),
66
+ ("confusion_matrix", "confusion matrix of a label output against its target with counts; pred and target name the "
67
+ "pair"),
68
+ ("image_pairs", "n test images above their first model output (reconstructions); needs the run"),
69
+ ("samples_grid", "a grid of the first n images in samples/<file>.pt, or the text of samples/<file>.txt"),
70
+ ("forecast_samples", "n windows of a horizon target: the truth and the prediction over the horizon steps"),
71
+ ("class_histogram", "the histogram of the positive class score per true class"),
72
+ ("binary_roc", "the ROC curve of a binary classification with its AUC"),
73
+ ("binary_precision_recall_curve", "the precision recall curve of a binary classification with its AP"),
74
+ ):
75
+ register(_name, f"kalfa.std.plots.{_name}", slot="plot", doc=_doc)
@@ -0,0 +1,207 @@
1
+ import numpy
2
+
3
+ from kalfa.core.errors import KalfaError
4
+ from kalfa.records import reader, stats
5
+ from kalfa.std.plots.common import INK, LEGEND_LIMIT, MUTED, color_of, directory_of, dress, label_text, quiet
6
+ from kalfa.std.plots.pairs import chosen_pair, pick_pair, read_predictions
7
+
8
+
9
+ MAX_CLASSES = 64
10
+ SKEW = 20.0
11
+
12
+
13
+ def label_set(truth, guess):
14
+ values = set(truth.tolist()) | set(guess.tolist())
15
+ try:
16
+ return sorted(values)
17
+ except TypeError:
18
+ return sorted(values, key=str)
19
+
20
+
21
+ def confusion_matrix(record, figures, pred=None, target=None):
22
+ directory = directory_of(record)
23
+ frame = read_predictions(directory)
24
+ if frame is None:
25
+ return None
26
+ pair = pick_pair(frame, quiet(reader.read_plan, directory), pred, target)
27
+ if pair is None:
28
+ return None
29
+ column, truth_column = pair
30
+ keep = frame[column].notna() & frame[truth_column].notna()
31
+ guess = frame.loc[keep, column].to_numpy()
32
+ truth = frame.loc[keep, truth_column].to_numpy()
33
+ labels = label_set(truth, guess)
34
+ if not labels or len(labels) > MAX_CLASSES:
35
+ return None
36
+ index = {label: position for position, label in enumerate(labels)}
37
+ matrix = numpy.zeros((len(labels), len(labels)), dtype="int64")
38
+ for true_value, guess_value in zip(truth.tolist(), guess.tolist()):
39
+ matrix[index[true_value], index[guess_value]] += 1
40
+ shares = matrix / numpy.maximum(matrix.sum(axis=1, keepdims=True), 1)
41
+ side = min(16.0, 2.0 + 0.7 * len(labels))
42
+ figure = figures.new(side + 1.2, side)
43
+ axis = figure.subplots()
44
+ drawn = axis.imshow(shares, cmap="Blues", vmin=0.0, vmax=1.0)
45
+ for row in range(len(labels)):
46
+ for col in range(len(labels)):
47
+ axis.text(col, row, f"{matrix[row, col]:,}\n{shares[row, col] * 100:.0f}%", ha="center", va="center",
48
+ fontsize=8, color="#ffffff" if shares[row, col] > 0.55 else INK)
49
+ names = [label_text(label) for label in labels]
50
+ tilt = len(names) > 6 or max(len(name) for name in names) > 6
51
+ axis.set_xticks(range(len(names)), names, rotation=45 if tilt else 0, ha="right" if tilt else "center")
52
+ axis.set_yticks(range(len(names)), names)
53
+ axis.grid(False)
54
+ figure.colorbar(drawn, ax=axis, fraction=0.046, pad=0.03, label="row share")
55
+ axis.set_title(f"Confusion matrix ({truth_column}, n = {int(matrix.sum()):,})", loc="left")
56
+ axis.set_xlabel(f"predicted ({column[len('pred_'):]})")
57
+ axis.set_ylabel(f"true {truth_column}")
58
+ figures.save(figure, "confusion_matrix")
59
+ return None
60
+
61
+
62
+ def classified(directory, plot, pred, target):
63
+ frame = stats.read_predictions(directory)
64
+ if frame is None or not len(frame):
65
+ return None
66
+ plan = quiet(reader.read_plan, directory)
67
+ fitted = quiet(reader.fields_of, directory)
68
+ pair = chosen_pair(frame, plan, fitted, pred, target, prefer="classification")
69
+ if pair is None:
70
+ return None
71
+ if pair["kind"] != "classification":
72
+ raise KalfaError(f"{plot}: the predictions carry no probabilities for {pair['pred']}; its target "
73
+ f"{pair['target']} is a regression target, not a label")
74
+ return frame, plan, fitted, pair
75
+
76
+
77
+ def score_column(pair, classes):
78
+ scores = pair["scores"]
79
+ if scores:
80
+ named = stats.positive_column(scores, pair["output"], classes) if classes and len(classes) == 2 else None
81
+ return named or scores[-1]
82
+ return pair["raw"]
83
+
84
+
85
+ def class_histogram(record, figures, pred=None, target=None, bins=40):
86
+ import pandas
87
+ from matplotlib.colors import to_rgba
88
+
89
+ directory = directory_of(record)
90
+ found = classified(directory, "class_histogram", pred, target)
91
+ if found is None:
92
+ return None
93
+ frame, plan, fitted, pair = found
94
+ output, truth_column = pair["output"], pair["target"]
95
+ classes = stats.classes_for(plan, fitted, truth_column)
96
+ column = score_column(pair, classes)
97
+ if column is None:
98
+ raise KalfaError(f"class_histogram: the predictions carry no probabilities (prob_{output}_<class>) and no "
99
+ f"raw_{output} scores for {pair['pred']}")
100
+ scores = pandas.to_numeric(frame[column], errors="coerce").to_numpy(dtype="float64")
101
+ known = frame[truth_column].notna().to_numpy()
102
+ keep = numpy.isfinite(scores) & known
103
+ if not keep.any():
104
+ return None
105
+ scores = scores[keep]
106
+ truth = numpy.asarray([label_text(value) for value in frame.loc[keep, truth_column].tolist()], dtype=object)
107
+ seen = sorted({str(value) for value in truth.tolist()})
108
+ order = list(classes or [])
109
+ labels = [label for label in order if label in seen] + [label for label in seen if label not in order]
110
+ if len(labels) > MAX_CLASSES:
111
+ return None
112
+ if column.startswith("prob_") and scores.min() >= 0.0 and scores.max() <= 1.0:
113
+ edges = numpy.linspace(0.0, 1.0, int(bins) + 1)
114
+ else:
115
+ edges = numpy.histogram_bin_edges(scores, bins=int(bins))
116
+ counts = [numpy.histogram(scores[truth == label], bins=edges)[0] for label in labels]
117
+ peaks = [int(count.max()) for count in counts if count.max() > 0]
118
+ log = len(peaks) > 1 and max(peaks) / min(peaks) > SKEW
119
+ figure = figures.new(8.0, 5.0)
120
+ axis = figure.subplots()
121
+ for position, (label, count) in enumerate(zip(labels, counts)):
122
+ color = color_of(figures, position)
123
+ axis.stairs(count, edges, fill=True, baseline=0.5 if log else 0.0, facecolor=to_rgba(color, 0.3),
124
+ edgecolor=color, linewidth=1.4, label=f"{label} (n = {int(count.sum()):,})")
125
+ if log:
126
+ axis.set_yscale("log")
127
+ axis.set_ylim(bottom=0.5)
128
+ if len(labels) <= LEGEND_LIMIT:
129
+ axis.legend(loc="upper center", frameon=False, title=f"true {truth_column}")
130
+ prefix = f"prob_{output}_"
131
+ axis.set_xlabel(f"P({column[len(prefix):]})" if column.startswith(prefix) else column)
132
+ axis.set_ylabel("rows")
133
+ axis.set_title(f"Score by true class ({truth_column}, n = {len(scores):,})", loc="left")
134
+ dress(axis)
135
+ figures.save(figure, "class_histogram")
136
+ return None
137
+
138
+
139
+ def floats(values):
140
+ return numpy.asarray([numpy.nan if value is None else value for value in values], dtype="float64")
141
+
142
+
143
+ def binary_curve(record, plot, pred, target):
144
+ found = classified(directory_of(record), plot, pred, target)
145
+ if found is None:
146
+ return None
147
+ frame, plan, fitted, pair = found
148
+ output, truth_column = pair["output"], pair["target"]
149
+ if not pair["scores"]:
150
+ raise KalfaError(f"{plot}: the predictions carry no probabilities for {pair['pred']}; a binary curve reads "
151
+ f"the prob_{output}_<class> columns that a label target writes")
152
+ classes = stats.classes_for(plan, fitted, truth_column)
153
+ result = stats.classification_stats(frame, pair["pred"], truth_column, pair["raw"], pair["scores"], classes)
154
+ if len(result["classes"]) != 2:
155
+ raise KalfaError(f"{plot} needs two classes; {truth_column} has {len(result['classes'])}: "
156
+ f"{', '.join(result['classes'])}")
157
+ if result["roc"] is None:
158
+ return None
159
+ return result, pair
160
+
161
+
162
+ def binary_roc(record, figures, pred=None, target=None):
163
+ found = binary_curve(record, "binary_roc", pred, target)
164
+ if found is None:
165
+ return None
166
+ result, pair = found
167
+ roc = result["roc"]
168
+ figure = figures.new(5.6, 5.0)
169
+ axis = figure.subplots()
170
+ axis.plot(floats(roc["fpr"]), floats(roc["tpr"]), color=color_of(figures, 0), linewidth=2.0,
171
+ label=f"{pair['output']} (AUC {roc['auc']:.3f})")
172
+ axis.plot([0.0, 1.0], [0.0, 1.0], color=MUTED, linewidth=1.0, linestyle="--", label="chance")
173
+ axis.set_xlim(0.0, 1.0)
174
+ axis.set_ylim(0.0, 1.02)
175
+ axis.legend(loc="lower right", frameon=False)
176
+ positive = result["classes"][1]
177
+ axis.set_title(f"ROC ({pair['target']} = {positive}, AUC {roc['auc']:.3f}, n = {result['count']:,})", loc="left")
178
+ axis.set_xlabel("false positive rate")
179
+ axis.set_ylabel("true positive rate")
180
+ dress(axis)
181
+ figures.save(figure, "binary_roc")
182
+ return None
183
+
184
+
185
+ def binary_precision_recall_curve(record, figures, pred=None, target=None):
186
+ found = binary_curve(record, "binary_precision_recall_curve", pred, target)
187
+ if found is None:
188
+ return None
189
+ result, pair = found
190
+ pr = result["pr"]
191
+ positive = result["classes"][1]
192
+ rate = result["per_class"][positive]["support"] / result["count"]
193
+ figure = figures.new(5.6, 5.0)
194
+ axis = figure.subplots()
195
+ axis.step(floats(pr["recall"]), floats(pr["precision"]), where="post", color=color_of(figures, 0), linewidth=2.0,
196
+ label=f"{pair['output']} (AP {pr['ap']:.3f})")
197
+ axis.axhline(rate, color=MUTED, linewidth=1.0, linestyle="--", label=f"base rate {rate:.3f}")
198
+ axis.set_xlim(0.0, 1.0)
199
+ axis.set_ylim(0.0, 1.02)
200
+ axis.legend(loc="lower left", frameon=False)
201
+ axis.set_title(f"Precision and recall ({pair['target']} = {positive}, AP {pr['ap']:.3f}, "
202
+ f"n = {result['count']:,})", loc="left")
203
+ axis.set_xlabel("recall")
204
+ axis.set_ylabel("precision")
205
+ dress(axis)
206
+ figures.save(figure, "binary_precision_recall_curve")
207
+ return None
@@ -0,0 +1,44 @@
1
+ import math
2
+ from pathlib import Path
3
+
4
+
5
+ LEGEND_LIMIT = 12
6
+ GRID = "#e3e2dd"
7
+ INK = "#0b0b0b"
8
+ MUTED = "#8c8b86"
9
+
10
+
11
+ def directory_of(record):
12
+ return Path(getattr(record, "directory", record))
13
+
14
+
15
+ def quiet(read, directory):
16
+ try:
17
+ return read(directory)
18
+ except (OSError, ValueError):
19
+ return None
20
+
21
+
22
+ def color_of(figures, position):
23
+ colors = getattr(figures, "colors", None) or tuple(f"C{index}" for index in range(10))
24
+ return colors[position % len(colors)]
25
+
26
+
27
+ def dress(axis):
28
+ axis.grid(True, color=GRID, linewidth=0.8)
29
+ axis.set_axisbelow(True)
30
+ for side in ("top", "right"):
31
+ axis.spines[side].set_visible(False)
32
+
33
+
34
+ def spans_decades(values):
35
+ finite = [value for value in values if math.isfinite(value)]
36
+ if not finite or any(value <= 0 for value in finite):
37
+ return False
38
+ return max(finite) / min(finite) > 100.0
39
+
40
+
41
+ def label_text(value):
42
+ if isinstance(value, float) and value.is_integer():
43
+ return str(int(value))
44
+ return str(value)