modulearn 0.1.0__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.
- modulearn/__init__.py +16 -0
- modulearn/cli.py +88 -0
- modulearn/compiler.py +238 -0
- modulearn/demo.py +81 -0
- modulearn/registry.py +283 -0
- modulearn/server.py +195 -0
- modulearn/static/graph-engine.js +623 -0
- modulearn/static/graph.html +170 -0
- modulearn/static/graph.js +1003 -0
- modulearn-0.1.0.dist-info/METADATA +163 -0
- modulearn-0.1.0.dist-info/RECORD +14 -0
- modulearn-0.1.0.dist-info/WHEEL +4 -0
- modulearn-0.1.0.dist-info/entry_points.txt +2 -0
- modulearn-0.1.0.dist-info/licenses/LICENSE +21 -0
modulearn/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""ModuLearn — a visual, node-graph training editor you wire to your own models.
|
|
2
|
+
|
|
3
|
+
Describe your datasets/models/hyperparameters with a :class:`Registry`, pass it to
|
|
4
|
+
:func:`create_app` with an ``on_train`` callback, and you get an Unreal-Blueprints
|
|
5
|
+
style canvas: drop nodes, wire them, launch, and watch training live on the graph.
|
|
6
|
+
"""
|
|
7
|
+
from modulearn.registry import Registry, Param, Port, NodeSpec, PORT_TYPES, CATEGORIES
|
|
8
|
+
from modulearn.compiler import compile_graph, CompiledGraph, GraphError
|
|
9
|
+
from modulearn.server import create_app, RunReporter
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Registry", "Param", "Port", "NodeSpec", "PORT_TYPES", "CATEGORIES",
|
|
13
|
+
"compile_graph", "CompiledGraph", "GraphError",
|
|
14
|
+
"create_app", "RunReporter",
|
|
15
|
+
]
|
|
16
|
+
__version__ = "0.1.0"
|
modulearn/cli.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""``modulearn`` command-line entry point.
|
|
2
|
+
|
|
3
|
+
modulearn demo # serve the bundled demo editor
|
|
4
|
+
modulearn run app.py # serve YOUR app's FastAPI object (attr `app`)
|
|
5
|
+
modulearn run app.py:editor # ...or a differently-named attribute
|
|
6
|
+
modulearn --version
|
|
7
|
+
|
|
8
|
+
``run`` loads a Python file that builds an app with ``create_app`` and serves it.
|
|
9
|
+
The target is a FastAPI instance (e.g. ``app = create_app(reg, on_train)``) or a
|
|
10
|
+
zero-arg factory that returns one.
|
|
11
|
+
"""
|
|
12
|
+
import argparse
|
|
13
|
+
import importlib.util
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from modulearn import __version__
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_app(target: str):
|
|
21
|
+
"""Resolve ``path.py`` or ``path.py:attr`` to a FastAPI app object."""
|
|
22
|
+
path_str, _, attr = target.partition(":")
|
|
23
|
+
attr = attr or "app"
|
|
24
|
+
path = Path(path_str).resolve()
|
|
25
|
+
if not path.exists():
|
|
26
|
+
sys.exit(f"modulearn: no such file: {path_str}")
|
|
27
|
+
|
|
28
|
+
# Import the file as a module, with its own directory on sys.path so the
|
|
29
|
+
# user's app can import sibling modules.
|
|
30
|
+
sys.path.insert(0, str(path.parent))
|
|
31
|
+
spec = importlib.util.spec_from_file_location(path.stem, path)
|
|
32
|
+
module = importlib.util.module_from_spec(spec)
|
|
33
|
+
try:
|
|
34
|
+
spec.loader.exec_module(module)
|
|
35
|
+
except Exception as e: # surface the user's import error cleanly
|
|
36
|
+
sys.exit(f"modulearn: failed to import {path_str}: {e}")
|
|
37
|
+
|
|
38
|
+
obj = getattr(module, attr, None)
|
|
39
|
+
if obj is None:
|
|
40
|
+
sys.exit(f"modulearn: {path_str} has no attribute '{attr}' "
|
|
41
|
+
f"(expected a FastAPI app or a factory returning one)")
|
|
42
|
+
return obj() if callable(obj) and not _is_asgi_app(obj) else obj
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_asgi_app(obj) -> bool:
|
|
46
|
+
"""A FastAPI/Starlette instance is callable too — tell it apart from a factory."""
|
|
47
|
+
return obj.__class__.__module__.startswith(("fastapi", "starlette"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _serve(app, host: str, port: int):
|
|
51
|
+
import uvicorn
|
|
52
|
+
url = f"http://{'localhost' if host in ('0.0.0.0', '') else host}:{port}"
|
|
53
|
+
print(f"ModuLearn → {url} (Ctrl-C to stop)")
|
|
54
|
+
uvicorn.run(app, host=host, port=port)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main(argv=None):
|
|
58
|
+
parser = argparse.ArgumentParser(
|
|
59
|
+
prog="modulearn",
|
|
60
|
+
description="Visual, node-graph editor for building and launching ML training runs.")
|
|
61
|
+
parser.add_argument("-V", "--version", action="version",
|
|
62
|
+
version=f"modulearn {__version__}")
|
|
63
|
+
sub = parser.add_subparsers(dest="command")
|
|
64
|
+
|
|
65
|
+
p_demo = sub.add_parser("demo", help="serve the bundled demo editor")
|
|
66
|
+
p_demo.add_argument("--host", default="127.0.0.1")
|
|
67
|
+
p_demo.add_argument("--port", type=int, default=8000)
|
|
68
|
+
|
|
69
|
+
p_run = sub.add_parser("run", help="serve your own app.py (an app built with create_app)")
|
|
70
|
+
p_run.add_argument("target", metavar="APP",
|
|
71
|
+
help="path to your app file, e.g. train.py or train.py:app")
|
|
72
|
+
p_run.add_argument("--host", default="127.0.0.1")
|
|
73
|
+
p_run.add_argument("--port", type=int, default=8000)
|
|
74
|
+
|
|
75
|
+
args = parser.parse_args(argv)
|
|
76
|
+
|
|
77
|
+
if args.command == "demo":
|
|
78
|
+
from modulearn.demo import build_app
|
|
79
|
+
_serve(build_app(), args.host, args.port)
|
|
80
|
+
elif args.command == "run":
|
|
81
|
+
_serve(_load_app(args.target), args.host, args.port)
|
|
82
|
+
else:
|
|
83
|
+
parser.print_help()
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
main()
|
modulearn/compiler.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Compile a visual ('Blueprints') canvas into a validated :class:`CompiledGraph`.
|
|
3
|
+
|
|
4
|
+
The frontend serializes the canvas to the compact **graph JSON** below and POSTs it
|
|
5
|
+
here. This module walks that graph, type-checks every wire against the registry's
|
|
6
|
+
catalog, runs any project-specific semantic checks, and — if valid — hands back a
|
|
7
|
+
plain :class:`CompiledGraph`. Your ``on_train`` callback turns that into whatever
|
|
8
|
+
config object your trainer wants. Compilation is pure and side-effect-free, so it is
|
|
9
|
+
cheap to unit-test with no web server or ML framework in the loop.
|
|
10
|
+
|
|
11
|
+
THE GRAPH JSON (input to :func:`compile_graph`)
|
|
12
|
+
-----------------------------------------------
|
|
13
|
+
{
|
|
14
|
+
"nodes": [
|
|
15
|
+
{"id": "n1", "type": "dataset.iris", "params": {}},
|
|
16
|
+
{"id": "n2", "type": "model.mlp", "params": {"hidden": [64, 32]}},
|
|
17
|
+
{"id": "n3", "type": "hyperparameter.lr", "params": {"lr": 5e-4}},
|
|
18
|
+
{"id": "n4", "type": "loss.loss", "params": {"loss": "mse"}},
|
|
19
|
+
{"id": "n9", "type": "train.train", "params": {}}
|
|
20
|
+
],
|
|
21
|
+
"links": [
|
|
22
|
+
{"from": "n1", "from_port": "dataset", "to": "n2", "to_port": "dataset"},
|
|
23
|
+
{"from": "n2", "from_port": "model", "to": "n9", "to_port": "model"},
|
|
24
|
+
{"from": "n4", "from_port": "loss", "to": "n9", "to_port": "loss"},
|
|
25
|
+
{"from": "n3", "from_port": "value", "to": "n9", "to_port": "lr"}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
VALIDATION (all failures collected, then raised together)
|
|
30
|
+
---------------------------------------------------------
|
|
31
|
+
:func:`compile_graph` raises :class:`GraphError` (carrying the full list, so the UI
|
|
32
|
+
can flag every problem at once) on: unknown/duplicate node, a link to a missing node
|
|
33
|
+
or port, a **type mismatch** on any wire (scalars being field-specific catches
|
|
34
|
+
lr→epochs), not exactly one Train node, an unconnected/mis-sourced ``model`` input,
|
|
35
|
+
a Model→Transform→…→Dataset chain that doesn't terminate at a Dataset, a wired
|
|
36
|
+
``experimental`` node, a feature-only dataset, or any host semantic check failing.
|
|
37
|
+
"""
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import uuid
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
|
|
43
|
+
from modulearn.registry import Registry
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class GraphError(Exception):
|
|
47
|
+
"""Raised when a graph can't compile. ``.errors`` is the full problem list."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, errors: list[str]):
|
|
50
|
+
self.errors = errors
|
|
51
|
+
super().__init__("; ".join(errors))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class CompiledGraph:
|
|
56
|
+
"""The validated result of a canvas. Everything a trainer needs, no node-graph
|
|
57
|
+
or wire concepts left. ``model`` is the short model name (``model.mlp`` → ``mlp``);
|
|
58
|
+
``hyperparameters`` maps each wired knob to its value."""
|
|
59
|
+
run_id: str
|
|
60
|
+
dataset: str
|
|
61
|
+
model: str
|
|
62
|
+
transforms: tuple[str, ...] = ()
|
|
63
|
+
model_params: dict = field(default_factory=dict)
|
|
64
|
+
loss_params: dict = field(default_factory=dict)
|
|
65
|
+
hyperparameters: dict = field(default_factory=dict)
|
|
66
|
+
meta: dict = field(default_factory=dict) # {"dataset_meta":..., "model_meta":...}
|
|
67
|
+
|
|
68
|
+
def as_dict(self) -> dict:
|
|
69
|
+
return {
|
|
70
|
+
"run_id": self.run_id, "dataset": self.dataset, "model": self.model,
|
|
71
|
+
"transforms": list(self.transforms), "model_params": self.model_params,
|
|
72
|
+
"loss_params": self.loss_params, "hyperparameters": self.hyperparameters,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _index_catalog(reg: Registry) -> dict[str, dict]:
|
|
77
|
+
idx = {}
|
|
78
|
+
for n in reg.catalog()["nodes"]:
|
|
79
|
+
idx[n["id"]] = {
|
|
80
|
+
**n,
|
|
81
|
+
"_in": {p["name"]: p["type"] for p in n["inputs"]},
|
|
82
|
+
"_out": {p["name"]: p["type"] for p in n["outputs"]},
|
|
83
|
+
}
|
|
84
|
+
return idx
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _params_with_defaults(catalog: dict, gnode: dict) -> dict:
|
|
88
|
+
spec = catalog[gnode["type"]]
|
|
89
|
+
out = {p["name"]: p.get("default") for p in spec["params"]}
|
|
90
|
+
out.update(gnode.get("params") or {})
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def compile_graph(graph: dict, reg: Registry,
|
|
95
|
+
run_id: str | None = None) -> CompiledGraph:
|
|
96
|
+
"""Validate ``graph`` against ``reg`` and compile it to a :class:`CompiledGraph`.
|
|
97
|
+
|
|
98
|
+
Raises :class:`GraphError` with every problem found. ``run_id`` is used verbatim
|
|
99
|
+
when given, else generated as ``<dataset>_<hex8>``."""
|
|
100
|
+
catalog = _index_catalog(reg)
|
|
101
|
+
errors: list[str] = []
|
|
102
|
+
|
|
103
|
+
raw_nodes = graph.get("nodes", []) or []
|
|
104
|
+
raw_links = graph.get("links", []) or []
|
|
105
|
+
|
|
106
|
+
# -- index nodes, catching unknown types / duplicate ids -----------------
|
|
107
|
+
gnodes: dict[str, dict] = {}
|
|
108
|
+
for gn in raw_nodes:
|
|
109
|
+
nid, ntype = gn.get("id"), gn.get("type")
|
|
110
|
+
if nid is None or ntype is None:
|
|
111
|
+
errors.append(f"node missing id or type: {gn!r}")
|
|
112
|
+
continue
|
|
113
|
+
if nid in gnodes:
|
|
114
|
+
errors.append(f"duplicate node id {nid!r}")
|
|
115
|
+
continue
|
|
116
|
+
if ntype not in catalog:
|
|
117
|
+
errors.append(f"node {nid!r} has unknown type {ntype!r}")
|
|
118
|
+
continue
|
|
119
|
+
gnodes[nid] = gn
|
|
120
|
+
|
|
121
|
+
# -- validate every link: endpoints, ports, TYPES MATCH ------------------
|
|
122
|
+
incoming: dict[str, dict[str, tuple[str, str]]] = {nid: {} for nid in gnodes}
|
|
123
|
+
for lk in raw_links:
|
|
124
|
+
src, dst = lk.get("from"), lk.get("to")
|
|
125
|
+
sp, dp = lk.get("from_port"), lk.get("to_port")
|
|
126
|
+
if src not in gnodes or dst not in gnodes:
|
|
127
|
+
errors.append(f"link references unknown node(s): {lk!r}")
|
|
128
|
+
continue
|
|
129
|
+
src_out = catalog[gnodes[src]["type"]]["_out"]
|
|
130
|
+
dst_in = catalog[gnodes[dst]["type"]]["_in"]
|
|
131
|
+
if sp not in src_out:
|
|
132
|
+
errors.append(f"node {src!r} has no output port {sp!r}")
|
|
133
|
+
continue
|
|
134
|
+
if dp not in dst_in:
|
|
135
|
+
errors.append(f"node {dst!r} has no input port {dp!r}")
|
|
136
|
+
continue
|
|
137
|
+
if src_out[sp] != dst_in[dp]:
|
|
138
|
+
errors.append(f"type mismatch on wire {src}.{sp} ({src_out[sp]}) -> "
|
|
139
|
+
f"{dst}.{dp} ({dst_in[dp]})")
|
|
140
|
+
continue
|
|
141
|
+
if dp in incoming[dst]:
|
|
142
|
+
errors.append(f"input {dst!r}.{dp!r} is wired more than once")
|
|
143
|
+
continue
|
|
144
|
+
incoming[dst][dp] = (src, sp)
|
|
145
|
+
|
|
146
|
+
# -- reject experimental nodes that are actually wired in ----------------
|
|
147
|
+
wired = {nid for lk in raw_links for nid in (lk.get("from"), lk.get("to"))
|
|
148
|
+
if nid in gnodes}
|
|
149
|
+
for nid in wired:
|
|
150
|
+
if catalog[gnodes[nid]["type"]].get("experimental"):
|
|
151
|
+
errors.append(f"node {nid!r} ({gnodes[nid]['type']}) is experimental "
|
|
152
|
+
f"and cannot be compiled yet — remove it from the run")
|
|
153
|
+
|
|
154
|
+
if errors:
|
|
155
|
+
raise GraphError(errors)
|
|
156
|
+
|
|
157
|
+
# -- exactly one Train sink ----------------------------------------------
|
|
158
|
+
trains = [nid for nid, gn in gnodes.items() if gn["type"] == "train.train"]
|
|
159
|
+
if len(trains) != 1:
|
|
160
|
+
raise GraphError([f"graph needs exactly one Train node, found {len(trains)}"])
|
|
161
|
+
train_id = trains[0]
|
|
162
|
+
|
|
163
|
+
# -- resolve model <- (transforms) <- dataset ----------------------------
|
|
164
|
+
model_link = incoming[train_id].get("model")
|
|
165
|
+
if not model_link:
|
|
166
|
+
raise GraphError(["Train node's 'model' input is not connected"])
|
|
167
|
+
model_id = model_link[0]
|
|
168
|
+
model_type = gnodes[model_id]["type"]
|
|
169
|
+
|
|
170
|
+
transforms: list[str] = []
|
|
171
|
+
cur = model_id
|
|
172
|
+
while True:
|
|
173
|
+
link = incoming[cur].get("dataset")
|
|
174
|
+
if not link:
|
|
175
|
+
raise GraphError([f"node {cur!r} 'dataset' input is not connected"])
|
|
176
|
+
src_id = link[0]
|
|
177
|
+
src_type = gnodes[src_id]["type"]
|
|
178
|
+
if src_type.startswith("transform."):
|
|
179
|
+
transforms.append(src_type.split(".", 1)[1])
|
|
180
|
+
cur = src_id
|
|
181
|
+
elif src_type.startswith("dataset."):
|
|
182
|
+
ds_id = src_id
|
|
183
|
+
break
|
|
184
|
+
else:
|
|
185
|
+
raise GraphError([f"node {cur!r} 'dataset' input must come from a "
|
|
186
|
+
f"Dataset or Transform, not {src_type!r}"])
|
|
187
|
+
transforms.reverse()
|
|
188
|
+
|
|
189
|
+
ds_key = gnodes[ds_id]["type"].split(".", 1)[1]
|
|
190
|
+
ds_meta = catalog[gnodes[ds_id]["type"]].get("meta", {})
|
|
191
|
+
model_meta = catalog[model_type].get("meta", {})
|
|
192
|
+
|
|
193
|
+
# -- gather params -------------------------------------------------------
|
|
194
|
+
model_params = _params_with_defaults(catalog, gnodes[model_id])
|
|
195
|
+
loss_params: dict = {}
|
|
196
|
+
loss_link = incoming[train_id].get("loss")
|
|
197
|
+
if loss_link:
|
|
198
|
+
loss_params = _params_with_defaults(catalog, gnodes[loss_link[0]])
|
|
199
|
+
|
|
200
|
+
hyper: dict = {}
|
|
201
|
+
for port_name, (src_id, _sp) in incoming[train_id].items():
|
|
202
|
+
if port_name in ("model", "loss"):
|
|
203
|
+
continue
|
|
204
|
+
hp = _params_with_defaults(catalog, gnodes[src_id])
|
|
205
|
+
if port_name in hp:
|
|
206
|
+
hyper[port_name] = hp[port_name]
|
|
207
|
+
|
|
208
|
+
compiled = CompiledGraph(
|
|
209
|
+
run_id=run_id or f"{ds_key}_{uuid.uuid4().hex[:8]}",
|
|
210
|
+
dataset=ds_key, model=model_type.split(".", 1)[1],
|
|
211
|
+
transforms=tuple(transforms), model_params=model_params,
|
|
212
|
+
loss_params=loss_params, hyperparameters=hyper,
|
|
213
|
+
meta={"dataset_meta": ds_meta, "model_meta": model_meta},
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# -- semantic checks: built-in kind/trainable rules, then host rules -----
|
|
217
|
+
sem: list[str] = list(_builtin_checks(compiled))
|
|
218
|
+
for check in reg.semantic_checks:
|
|
219
|
+
sem.extend(check(compiled) or [])
|
|
220
|
+
if sem:
|
|
221
|
+
raise GraphError(sem)
|
|
222
|
+
|
|
223
|
+
return compiled
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _builtin_checks(c: CompiledGraph) -> list[str]:
|
|
227
|
+
"""Always-on rules derived from registry metadata: a dataset must be trainable,
|
|
228
|
+
and a model's ``requires_kind`` (if set) must match the dataset's ``kind``."""
|
|
229
|
+
errs: list[str] = []
|
|
230
|
+
ds = c.meta.get("dataset_meta", {})
|
|
231
|
+
if not ds.get("trainable", True) or not ds.get("targets"):
|
|
232
|
+
errs.append(f"dataset {c.dataset!r} is feature-only and cannot be trained")
|
|
233
|
+
need = c.meta.get("model_meta", {}).get("requires_kind")
|
|
234
|
+
have = ds.get("kind")
|
|
235
|
+
if need and have and need != have:
|
|
236
|
+
errs.append(f"model {c.model!r} needs a {need!r} dataset but "
|
|
237
|
+
f"{c.dataset!r} is {have!r}")
|
|
238
|
+
return errs
|
modulearn/demo.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""A complete, dependency-free demo app shipped inside the package.
|
|
2
|
+
|
|
3
|
+
This is what ``modulearn demo`` serves. It mirrors ``examples/quickstart.py``
|
|
4
|
+
(the teaching file), but lives in the package so it ships in the wheel and can be
|
|
5
|
+
launched without the source checkout. Swap ``on_train`` for a real trainer and the
|
|
6
|
+
same editor drives it.
|
|
7
|
+
"""
|
|
8
|
+
import math
|
|
9
|
+
import random
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from modulearn import Registry, Param, create_app
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_registry() -> Registry:
|
|
16
|
+
"""The declarative surface: what this demo can train."""
|
|
17
|
+
reg = Registry()
|
|
18
|
+
|
|
19
|
+
reg.add_dataset("iris", title="Iris (tabular)", kind="tabular",
|
|
20
|
+
features=["sepal_len", "sepal_wid", "petal_len", "petal_wid"],
|
|
21
|
+
targets=["species"], help="Classic 4-feature flower dataset.")
|
|
22
|
+
reg.add_dataset("digits", title="Digits (tabular)", kind="tabular",
|
|
23
|
+
features=[f"px{i}" for i in range(64)], targets=["digit"],
|
|
24
|
+
help="8x8 handwritten digits, flattened.")
|
|
25
|
+
reg.add_dataset("features_only", title="Unlabeled pool", kind="tabular",
|
|
26
|
+
features=["a", "b", "c"], targets=[], trainable=False,
|
|
27
|
+
help="No target column — the compiler will refuse to train it.")
|
|
28
|
+
|
|
29
|
+
reg.add_model("mlp", title="MLP (feed-forward net)", title_short="MLP",
|
|
30
|
+
requires_kind="tabular",
|
|
31
|
+
help="Dense ReLU network over tabular features.",
|
|
32
|
+
params=[
|
|
33
|
+
Param("hidden", "hidden layers", "int_list", [64, 32],
|
|
34
|
+
help="Comma-separated widths, e.g. 64,32."),
|
|
35
|
+
Param("dropout", "dropout", "float", 0.0, min=0.0, max=0.9),
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
reg.add_hyperparameter("lr", label="learning rate", kind="float", default=1e-3,
|
|
39
|
+
min=1e-6, max=1.0, help="Step size.")
|
|
40
|
+
reg.add_hyperparameter("epochs", label="epochs", kind="int", default=60,
|
|
41
|
+
min=1, max=2000, help="Total training epochs.")
|
|
42
|
+
reg.add_hyperparameter("seed", label="seed", kind="int", default=0, min=0,
|
|
43
|
+
max=2**31 - 1, help="Reproducibility seed.")
|
|
44
|
+
|
|
45
|
+
reg.add_loss([
|
|
46
|
+
Param("loss", "kind", "enum", "cross_entropy",
|
|
47
|
+
choices=["cross_entropy", "mse"], help="Objective."),
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
reg.add_transform("standardize", title="Standardize",
|
|
51
|
+
help="Zero-mean, unit-variance each feature.")
|
|
52
|
+
reg.add_transform("pca", title="PCA (whiten)", live=False,
|
|
53
|
+
help="Dimensionality reduction — not wired up in this demo.")
|
|
54
|
+
return reg
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def on_train(compiled, reporter):
|
|
58
|
+
"""A toy loss curve so the demo runs anywhere — no ML deps."""
|
|
59
|
+
hp = compiled.hyperparameters
|
|
60
|
+
epochs = int(hp.get("epochs", 60))
|
|
61
|
+
lr = float(hp.get("lr", 1e-3))
|
|
62
|
+
random.seed(int(hp.get("seed", 0)))
|
|
63
|
+
reporter.state(epochs=epochs)
|
|
64
|
+
|
|
65
|
+
best = float("inf")
|
|
66
|
+
floor = 0.15 + random.random() * 0.1
|
|
67
|
+
for e in range(epochs):
|
|
68
|
+
t = e / max(1, epochs - 1)
|
|
69
|
+
train = floor + (1.4 * math.exp(-4 * lr * 100 * t)) + random.uniform(-0.02, 0.02)
|
|
70
|
+
val = train + 0.05 + random.uniform(0, 0.04)
|
|
71
|
+
best = min(best, val)
|
|
72
|
+
reporter.metric(epoch=e, train=round(train, 4), val=round(val, 4))
|
|
73
|
+
reporter.state(epoch=e, best_val=round(best, 4))
|
|
74
|
+
time.sleep(0.05)
|
|
75
|
+
reporter.state(phase="done", test_score=round(best + 0.02, 4))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_app(runs_dir="runs"):
|
|
79
|
+
"""The FastAPI app ``modulearn demo`` serves."""
|
|
80
|
+
return create_app(build_registry(), on_train, title="ModuLearn",
|
|
81
|
+
subtitle="demo", runs_dir=runs_dir)
|