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
kalfa/core/spec.py ADDED
@@ -0,0 +1,413 @@
1
+ import re
2
+ from dataclasses import dataclass, field
3
+ from datetime import date, datetime
4
+ from pathlib import Path
5
+
6
+ from ruamel.yaml import YAML
7
+ from ruamel.yaml.constructor import DuplicateKeyError
8
+ from ruamel.yaml.error import YAMLError
9
+
10
+ from .errors import ProblemsError, error, warning
11
+
12
+
13
+ LOADER_KEYS = ("include", "params", "plugins")
14
+ PARAM_FLAG = "-p"
15
+ TOKEN = re.compile(r"\$\$|\$([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)\$")
16
+ BUILTINS = ("datetime",)
17
+ DATETIME_FORMAT = "%Y%m%d_%H%M%S"
18
+ MISSING = object()
19
+
20
+
21
+ @dataclass
22
+ class Layer:
23
+ label: str
24
+ data: dict
25
+ file: str | None = None
26
+ includes: list = field(default_factory=list)
27
+
28
+
29
+ @dataclass
30
+ class Config:
31
+ data: dict
32
+ params: dict
33
+ plugins: list
34
+ layers: list
35
+ directories: list
36
+ overrides: dict
37
+ problems: list = field(default_factory=list)
38
+
39
+ @property
40
+ def errors(self):
41
+ return [problem for problem in self.problems if problem.severity == "error"]
42
+
43
+ @property
44
+ def ok(self):
45
+ return not self.errors
46
+
47
+
48
+ def is_call(value):
49
+ return isinstance(value, dict) and "use" in value
50
+
51
+
52
+ def join(path, key):
53
+ if isinstance(key, int) and not isinstance(key, bool):
54
+ return f"{path}[{key}]"
55
+ return f"{path}.{key}" if path else str(key)
56
+
57
+
58
+ def kind(value):
59
+ return type(value).__name__
60
+
61
+
62
+ def scalar(value):
63
+ if isinstance(value, bool):
64
+ return bool(value)
65
+ if isinstance(value, int):
66
+ return int(value)
67
+ if isinstance(value, float):
68
+ return float(value)
69
+ if isinstance(value, str):
70
+ return str(value)
71
+ if isinstance(value, date):
72
+ return value.isoformat()
73
+ return value
74
+
75
+
76
+ def plain(value):
77
+ if isinstance(value, dict):
78
+ return {scalar(key): plain(item) for key, item in value.items()}
79
+ if isinstance(value, (list, tuple)):
80
+ return [plain(item) for item in value]
81
+ return scalar(value)
82
+
83
+
84
+ def clone(value):
85
+ if isinstance(value, dict):
86
+ return {key: clone(item) for key, item in value.items()}
87
+ if isinstance(value, list):
88
+ return [clone(item) for item in value]
89
+ return value
90
+
91
+
92
+ def listed(value):
93
+ if value is None:
94
+ return []
95
+ if isinstance(value, (str, Path, dict)):
96
+ return [value]
97
+ return list(value)
98
+
99
+
100
+ def describe(exc):
101
+ parts = [str(part).strip() for part in (getattr(exc, "context", None), getattr(exc, "problem", None)) if part]
102
+ mark = getattr(exc, "problem_mark", None) or getattr(exc, "context_mark", None)
103
+ line = mark.line + 1 if mark is not None else None
104
+ return ": ".join(parts) or str(exc).strip(), line
105
+
106
+
107
+ def located(path, exc):
108
+ message, line = describe(exc)
109
+ where = f"{path}:{line}" if line else str(path)
110
+ return f"{where}: {message}"
111
+
112
+
113
+ def parse_value(text):
114
+ if isinstance(text, str) and text.lstrip().startswith("@"):
115
+ return text.strip()
116
+ return plain(YAML(typ="safe").load(text))
117
+
118
+
119
+ def assignment(text, code, flag, what):
120
+ expected = f"{flag} expects {what}=VALUE, got {text!r}"
121
+ if not isinstance(text, str):
122
+ raise ProblemsError([error(code, expected)])
123
+ path, separator, raw = text.partition("=")
124
+ path = path.strip()
125
+ if not separator or not path:
126
+ raise ProblemsError([error(code, expected)])
127
+ try:
128
+ value = parse_value(raw.strip())
129
+ except YAMLError as exc:
130
+ message, _ = describe(exc)
131
+ raise ProblemsError([error(code, f"{flag} {path}: cannot parse value {raw!r}: {message}", path)]) from None
132
+ return path, value
133
+
134
+
135
+ def parse_set(text):
136
+ return assignment(text, "bad_set", "--set", "PATH")
137
+
138
+
139
+ def parse_param(text):
140
+ return assignment(text, "bad_param", "-p", "NAME")
141
+
142
+
143
+ def read_yaml(path):
144
+ path = Path(path)
145
+ try:
146
+ text = path.read_text(encoding="utf-8")
147
+ except OSError as exc:
148
+ raise ProblemsError([error("file_not_found", f"cannot read {path}: {exc.strerror or exc}")]) from None
149
+ except UnicodeDecodeError as exc:
150
+ raise ProblemsError([error("yaml_error", f"{path}: not utf-8 text ({exc.reason})")]) from None
151
+ try:
152
+ loaded = YAML(typ="safe").load(text)
153
+ except DuplicateKeyError as exc:
154
+ raise ProblemsError([error("duplicate_key", located(path, exc))]) from None
155
+ except YAMLError as exc:
156
+ raise ProblemsError([error("yaml_error", located(path, exc))]) from None
157
+ return plain(loaded)
158
+
159
+
160
+ def merge(base, incoming, path, problems, overrides, label):
161
+ for key, value in incoming.items():
162
+ child = join(path, key)
163
+ if key not in base:
164
+ base[key] = clone(value)
165
+ continue
166
+ existing = base[key]
167
+ if isinstance(existing, dict) and isinstance(value, dict) and not is_call(value):
168
+ merge(existing, value, child, problems, overrides, label)
169
+ continue
170
+ if existing != value or type(existing) is not type(value):
171
+ overrides[child] = label
172
+ base[key] = clone(value)
173
+ return base
174
+
175
+
176
+ def nested(pairs):
177
+ data = {}
178
+ for path, value in pairs:
179
+ parts = path.split(".")
180
+ target = data
181
+ for part in parts[:-1]:
182
+ if not isinstance(target.get(part), dict):
183
+ target[part] = {}
184
+ target = target[part]
185
+ target[parts[-1]] = value
186
+ return data
187
+
188
+
189
+ class Substitution:
190
+ def __init__(self, params, problems):
191
+ self.raw = params if isinstance(params, dict) else {}
192
+ self.problems = problems
193
+ self.builtins = {"datetime": datetime.now().strftime(DATETIME_FORMAT)}
194
+ self.done = {}
195
+ self.stack = []
196
+ self.used = set()
197
+
198
+ def resolved(self):
199
+ return {name: self.param(name, join("params", name)) for name in self.raw}
200
+
201
+ def param(self, name, path):
202
+ if name in self.done:
203
+ return self.done[name]
204
+ if name in self.stack:
205
+ chain = " -> ".join([*self.stack[self.stack.index(name):], name])
206
+ self.problems.append(error("param_cycle", f"param {name!r} refers to itself: {chain}", path))
207
+ return MISSING
208
+ self.stack.append(name)
209
+ value = self.walk(self.raw[name], join("params", name))
210
+ self.stack.pop()
211
+ self.done[name] = value
212
+ return value
213
+
214
+ def lookup(self, token, path):
215
+ base, _, member = token.partition(".")
216
+ if base in self.raw:
217
+ self.used.add(base)
218
+ holder = self.param(base, path)
219
+ if holder is MISSING:
220
+ return MISSING
221
+ elif base in self.builtins:
222
+ holder = self.builtins[base]
223
+ else:
224
+ self.problems.append(error("unknown_param", f"unknown param ${token}$", path, "define it under params"))
225
+ return MISSING
226
+ if not member:
227
+ return holder
228
+ if isinstance(holder, dict) and member in holder:
229
+ return holder[member]
230
+ self.problems.append(error("unknown_param", f"param {base!r} has no field {member!r} for ${token}$", path))
231
+ return MISSING
232
+
233
+ def text(self, value, path):
234
+ if "$" not in value:
235
+ return value
236
+ whole = TOKEN.fullmatch(value)
237
+ if whole and whole.group(1):
238
+ found = self.lookup(whole.group(1), path)
239
+ return value if found is MISSING else clone(found)
240
+
241
+ def piece(match):
242
+ if match.group(1) is None:
243
+ return "$"
244
+ found = self.lookup(match.group(1), path)
245
+ return match.group(0) if found is MISSING else str(found)
246
+
247
+ return TOKEN.sub(piece, value)
248
+
249
+ def walk(self, value, path):
250
+ if isinstance(value, dict):
251
+ return {key: self.walk(item, join(path, key)) for key, item in value.items()}
252
+ if isinstance(value, list):
253
+ return [self.walk(item, join(path, index)) for index, item in enumerate(value)]
254
+ if isinstance(value, str):
255
+ return self.text(value, path)
256
+ return value
257
+
258
+
259
+ def substitute(data, params, problems):
260
+ substitution = Substitution(params, problems)
261
+ substitution.resolved()
262
+ return substitution.walk(data, "")
263
+
264
+
265
+ class Loader:
266
+ def __init__(self):
267
+ self.problems = []
268
+ self.layers = []
269
+ self.directories = []
270
+ self.data = {}
271
+ self.params = {}
272
+ self.plugins = []
273
+ self.overrides = {}
274
+ self.done = set()
275
+ self.param_layers = {}
276
+
277
+ def file(self, raw, label, stack, includer=None):
278
+ path = Path(raw).expanduser()
279
+ resolved = str(path.resolve())
280
+ if resolved in stack:
281
+ chain = " -> ".join(Path(entry).name for entry in [*stack[stack.index(resolved):], resolved])
282
+ hint = f"included by {includer}" if includer else None
283
+ self.problems.append(error("include_cycle", f"include cycle: {chain}", hint=hint))
284
+ return
285
+ if resolved in self.done:
286
+ return
287
+ try:
288
+ loaded = read_yaml(path)
289
+ except ProblemsError as exc:
290
+ hint = f"included by {includer}" if includer else None
291
+ self.problems.extend(error(problem.code, problem.message, problem.path, hint) for problem in exc.problems)
292
+ return
293
+ self.done.add(resolved)
294
+ if loaded is None:
295
+ loaded = {}
296
+ if not isinstance(loaded, dict):
297
+ message = f"{path}: the top level must be a mapping, not {kind(loaded)}"
298
+ self.problems.append(error("not_a_mapping", message))
299
+ return
300
+ directory = str(Path(resolved).parent)
301
+ if directory not in self.directories:
302
+ self.directories.append(directory)
303
+ layer = Layer(label, loaded, resolved)
304
+ self.includes(loaded.get("include"), Path(resolved).parent, [*stack, resolved], layer, label)
305
+ self.layers.append(layer)
306
+ self.apply(layer)
307
+
308
+ def includes(self, entries, directory, stack, layer, includer):
309
+ if entries is None:
310
+ return
311
+ if isinstance(entries, str):
312
+ entries = [entries]
313
+ if not isinstance(entries, list):
314
+ self.problems.append(error("bad_include", f"{includer}: include must be a list of file paths", "include"))
315
+ return
316
+ for index, entry in enumerate(entries):
317
+ if not isinstance(entry, str):
318
+ message = f"{includer}: include entries must be file paths, got {entry!r}"
319
+ self.problems.append(error("bad_include", message, join("include", index)))
320
+ continue
321
+ target = Path(entry).expanduser()
322
+ if not target.is_absolute():
323
+ target = directory / target
324
+ layer.includes.append(str(target.resolve()))
325
+ self.file(target, entry, stack, includer)
326
+
327
+ def mapping(self, mapping, number):
328
+ if not isinstance(mapping, dict):
329
+ message = f"mapping {number} must be a mapping, not {kind(mapping)}"
330
+ self.problems.append(error("not_a_mapping", message))
331
+ return
332
+ layer = Layer("mapping", plain(mapping))
333
+ self.includes(layer.data.get("include"), Path.cwd(), [], layer, "mapping")
334
+ self.layers.append(layer)
335
+ self.apply(layer)
336
+
337
+ def assignments(self, items, parser, code, label, section=None):
338
+ pairs = []
339
+ for item in items:
340
+ if isinstance(item, str):
341
+ try:
342
+ pairs.append(parser(item))
343
+ except ProblemsError as exc:
344
+ self.problems.extend(exc.problems)
345
+ elif isinstance(item, (tuple, list)) and len(item) == 2 and isinstance(item[0], str):
346
+ pairs.append((item[0], item[1]))
347
+ else:
348
+ message = f"{label} expects NAME=VALUE text or a (name, value) pair, got {item!r}"
349
+ self.problems.append(error(code, message))
350
+ if not pairs:
351
+ return
352
+ data = nested(pairs)
353
+ layer = Layer(label, {section: data} if section else data)
354
+ self.layers.append(layer)
355
+ self.apply(layer)
356
+
357
+ def apply(self, layer):
358
+ label = layer.label
359
+ params = layer.data.get("params")
360
+ if params is not None:
361
+ if isinstance(params, dict):
362
+ merge(self.params, params, "params", self.problems, self.overrides, label)
363
+ self.param_layers.update(dict.fromkeys(params, label))
364
+ else:
365
+ self.problems.append(error("bad_params", f"{label}: params must be a mapping", "params"))
366
+ plugins = layer.data.get("plugins")
367
+ if isinstance(plugins, str):
368
+ plugins = [plugins]
369
+ if plugins is not None:
370
+ if not isinstance(plugins, list):
371
+ self.problems.append(error("bad_plugins", f"{label}: plugins must be a list of modules", "plugins"))
372
+ else:
373
+ self.pool(plugins, label)
374
+ own = {key: value for key, value in layer.data.items() if key not in LOADER_KEYS}
375
+ merge(self.data, own, "", self.problems, self.overrides, label)
376
+
377
+ def pool(self, plugins, label):
378
+ for index, entry in enumerate(plugins):
379
+ if not isinstance(entry, str):
380
+ message = f"{label}: plugin entries must be module names, got {entry!r}"
381
+ self.problems.append(error("bad_plugins", message, join("plugins", index)))
382
+ elif entry not in self.plugins:
383
+ self.plugins.append(entry)
384
+
385
+ def finish(self):
386
+ substitution = Substitution(self.params, self.problems)
387
+ params = substitution.resolved()
388
+ data = substitution.walk(self.data, "")
389
+ self.unused(substitution.used)
390
+ return Config(data, params, list(self.plugins), self.layers, self.directories, self.overrides, self.problems)
391
+
392
+ def unused(self, used):
393
+ for name in self.params:
394
+ if name in used:
395
+ continue
396
+ path = join("params", name)
397
+ if self.param_layers.get(name) == PARAM_FLAG:
398
+ message = f"{PARAM_FLAG} {name}: nothing in the config reads ${name}$"
399
+ hint = "a param is read as $name$; a leaf is overridden with --set path=value"
400
+ self.problems.append(error("unused_param", message, path, hint))
401
+ else:
402
+ self.problems.append(warning("unused_param", f"param {name!r} is not read by any $...$", path))
403
+
404
+
405
+ def load(paths=(), sets=(), params=(), mappings=()):
406
+ loader = Loader()
407
+ for raw in listed(paths):
408
+ loader.file(raw, str(raw), [])
409
+ for number, mapping in enumerate(listed(mappings), 1):
410
+ loader.mapping(mapping, number)
411
+ loader.assignments(listed(sets), parse_set, "bad_set", "--set")
412
+ loader.assignments(listed(params), parse_param, "bad_param", PARAM_FLAG, "params")
413
+ return loader.finish()
kalfa/core/state.py ADDED
@@ -0,0 +1,100 @@
1
+ import logging
2
+ from pathlib import Path
3
+
4
+ import torch
5
+
6
+ from .errors import KalfaError
7
+ from .record import atomic
8
+
9
+
10
+ FORMAT = 1
11
+
12
+
13
+ def has_state(obj):
14
+ getter = getattr(obj, "state_dict", None)
15
+ return callable(getter) and bool(getter())
16
+
17
+
18
+ def stateful_nodes(ctx):
19
+ for name, entry in ctx.models.items():
20
+ yield f"models.{name}", entry
21
+ if entry.ema is not None:
22
+ yield f"models.{name}.ema", entry.ema
23
+ for name, loss in ctx.losses.items():
24
+ if has_state(loss):
25
+ yield f"losses.{name}", loss
26
+ for name, metric in ctx.metrics.items():
27
+ if has_state(metric):
28
+ yield f"metrics.{name}", metric
29
+ for name, optimizer in ctx.optimizers.items():
30
+ yield f"optimizers.{name}", optimizer
31
+ if ctx.step is not None and has_state(ctx.step):
32
+ yield "train.step", ctx.step
33
+ for rule in ctx.rules:
34
+ yield f"rules.{rule.name}", rule
35
+
36
+
37
+ def payload(ctx):
38
+ nodes = {path: obj.state_dict() for path, obj in stateful_nodes(ctx)}
39
+ return {"format": FORMAT, "turn": int(ctx.turn), "update": int(ctx.updates), "nodes": nodes,
40
+ "rng": None if ctx.rng is None else ctx.rng.state_dict(), "patches": len(ctx.patches),
41
+ "best": None if ctx.best is None else float(ctx.best)}
42
+
43
+
44
+ def save_checkpoint(ctx, path):
45
+ data = payload(ctx)
46
+ with atomic(path) as temporary:
47
+ torch.save(data, temporary)
48
+ return Path(path)
49
+
50
+
51
+ def read_checkpoint(path):
52
+ return torch.load(path, map_location="cpu", weights_only=False)
53
+
54
+
55
+ def wanted(path, only):
56
+ if only is None:
57
+ return True
58
+ prefixes = (only,) if isinstance(only, str) else tuple(only)
59
+ return any(path == prefix or path.startswith(prefix.rstrip(".") + ".") for prefix in prefixes)
60
+
61
+
62
+ def load_checkpoint(ctx, path, only=None, counters=True, rng=True):
63
+ data = read_checkpoint(path)
64
+ if not isinstance(data, dict) or data.get("format") != FORMAT or "nodes" not in data:
65
+ raise KalfaError(f"{path} is not a kalfa checkpoint of format {FORMAT}")
66
+ objects = dict(stateful_nodes(ctx))
67
+ logger = getattr(ctx, "logger", None) or logging.getLogger("kalfa.run")
68
+ for node_path, state in (data.get("nodes") or {}).items():
69
+ if not wanted(node_path, only):
70
+ continue
71
+ obj = objects.get(node_path)
72
+ if obj is None:
73
+ logger.debug("checkpoint node %s has no object in this run and is skipped", node_path)
74
+ continue
75
+ obj.load_state_dict(state)
76
+ if counters:
77
+ ctx.turn = int(data.get("turn", ctx.turn))
78
+ ctx.updates = int(data.get("update", ctx.updates))
79
+ if "best" in data:
80
+ ctx.best = data["best"]
81
+ if rng and data.get("rng") is not None and getattr(ctx, "rng", None) is not None:
82
+ ctx.rng.load_state_dict(data["rng"])
83
+ return data
84
+
85
+
86
+ def checkpoint_path(directory, which=None):
87
+ base = Path(directory) / "checkpoints"
88
+ if which == "final":
89
+ path = base / "final.pt"
90
+ elif which == "best":
91
+ path = base / "best.pt"
92
+ elif which in (None, "last"):
93
+ path = base / "last.pt"
94
+ if which is None and not path.exists() and (base / "final.pt").exists():
95
+ path = base / "final.pt"
96
+ else:
97
+ raise KalfaError(f"which must be best, last or final, got {which!r}")
98
+ if not path.exists():
99
+ raise KalfaError(f"{directory} has no {path.relative_to(directory)}")
100
+ return path