redcedar 0.93.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.
- redcedar/__init__.py +7 -0
- redcedar/__main__.py +7 -0
- redcedar/analysis.py +74 -0
- redcedar/cli.py +136 -0
- redcedar/client.py +366 -0
- redcedar/datasets.py +45 -0
- redcedar/explainable.py +38 -0
- redcedar/inference.py +102 -0
- redcedar/local.py +29 -0
- redcedar/settings.py +56 -0
- redcedar/training.py +190 -0
- redcedar-0.93.0.dist-info/LICENSE +201 -0
- redcedar-0.93.0.dist-info/METADATA +89 -0
- redcedar-0.93.0.dist-info/RECORD +17 -0
- redcedar-0.93.0.dist-info/WHEEL +5 -0
- redcedar-0.93.0.dist-info/entry_points.txt +2 -0
- redcedar-0.93.0.dist-info/top_level.txt +1 -0
redcedar/__init__.py
ADDED
redcedar/__main__.py
ADDED
redcedar/analysis.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Dataset analysis — PCA, Mutual Information, Resource Estimation."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AnalysisManager:
|
|
5
|
+
"""Run dataset analysis operations on the server."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, client):
|
|
8
|
+
self._client = client
|
|
9
|
+
|
|
10
|
+
def pca(self, dataset_id, verbose=False, on_progress=None):
|
|
11
|
+
"""Run PCA analysis on a dataset.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
dataset_id: Dataset ID from the catalog (e.g., 'wdbc', 'iris').
|
|
15
|
+
verbose: Print progress to stdout; "log" also prints the server's log for the job.
|
|
16
|
+
on_progress: Callback(percent, message) for progress updates.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Dict with variance_ratios, cumulative_variance, thresholds, etc.
|
|
20
|
+
"""
|
|
21
|
+
if self._client.core: # local mode: call the core directly
|
|
22
|
+
return self._client._run_local(self._client.core.pca, dataset_id,
|
|
23
|
+
verbose=verbose, on_progress=on_progress)
|
|
24
|
+
return self._client._run_job(
|
|
25
|
+
"/analysis/pca",
|
|
26
|
+
"/analysis/pca/{job_id}/stream",
|
|
27
|
+
body={"dataset_id": dataset_id},
|
|
28
|
+
verbose=verbose,
|
|
29
|
+
on_progress=on_progress,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def mi(self, dataset_id, verbose=False, on_progress=None):
|
|
33
|
+
"""Run Mutual Information feature importance analysis.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
dataset_id: Dataset ID from the catalog.
|
|
37
|
+
verbose: Print progress to stdout; "log" also prints the server's log for the job.
|
|
38
|
+
on_progress: Callback(percent, message) for progress updates.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Dict with ranked_features, cumulative_mi, thresholds, etc.
|
|
42
|
+
"""
|
|
43
|
+
if self._client.core: # local mode: call the core directly
|
|
44
|
+
return self._client._run_local(self._client.core.mi, dataset_id,
|
|
45
|
+
verbose=verbose, on_progress=on_progress)
|
|
46
|
+
return self._client._run_job(
|
|
47
|
+
"/analysis/mi",
|
|
48
|
+
"/analysis/mi/{job_id}/stream",
|
|
49
|
+
body={"dataset_id": dataset_id},
|
|
50
|
+
verbose=verbose,
|
|
51
|
+
on_progress=on_progress,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def qre(self, dataset_id, verbose=False, on_progress=None):
|
|
55
|
+
"""Run Quantum Resource Estimation.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
dataset_id: Dataset ID from the catalog.
|
|
59
|
+
verbose: Print progress to stdout; "log" also prints the server's log for the job.
|
|
60
|
+
on_progress: Callback(percent, message) for progress updates.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Dict with estimatedQubits, standardDeviation, plotData, etc.
|
|
64
|
+
"""
|
|
65
|
+
if self._client.core: # local mode: call the core directly
|
|
66
|
+
return self._client._run_local(self._client.core.qre, dataset_id,
|
|
67
|
+
verbose=verbose, on_progress=on_progress)
|
|
68
|
+
return self._client._run_job(
|
|
69
|
+
"/analysis/qre",
|
|
70
|
+
"/analysis/qre/{job_id}/stream",
|
|
71
|
+
body={"dataset_id": dataset_id},
|
|
72
|
+
verbose=verbose,
|
|
73
|
+
on_progress=on_progress,
|
|
74
|
+
)
|
redcedar/cli.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""The redcedar command: train, evaluate and predict from the command line.
|
|
2
|
+
|
|
3
|
+
redcedar train my_data.csv --qubits 6 --seed 123
|
|
4
|
+
redcedar evaluate <train_id>
|
|
5
|
+
redcedar predict <train_id> new_data.csv -o predictions.csv
|
|
6
|
+
redcedar models
|
|
7
|
+
redcedar status
|
|
8
|
+
|
|
9
|
+
The server comes from --url / --api-key, or REDCEDAR_URL / REDCEDAR_API_KEY.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from .client import connect
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main(argv=None):
|
|
21
|
+
p = argparse.ArgumentParser(prog="redcedar", description="Red Cedar quantum machine learning")
|
|
22
|
+
p.add_argument("--url", help="server URL (default: REDCEDAR_URL or http://127.0.0.1:8002)")
|
|
23
|
+
p.add_argument("--api-key", help="API key (default: REDCEDAR_API_KEY)")
|
|
24
|
+
p.add_argument("--local", action="store_true",
|
|
25
|
+
help="call the Red Cedar core directly in this process instead of a server "
|
|
26
|
+
"(redcedar_core, installed with pip install -e, or the red-cedar folder "
|
|
27
|
+
"in REDCEDAR_CORE_PATH)")
|
|
28
|
+
p.add_argument("-q", "--quiet", action="store_true", help="no progress output")
|
|
29
|
+
p.add_argument("--log", action="store_true", help="also print the server's log for the job")
|
|
30
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
31
|
+
|
|
32
|
+
t = sub.add_parser("train", help="train a model on a CSV file or catalog dataset")
|
|
33
|
+
t.add_argument("data", help="CSV file path or catalog dataset ID")
|
|
34
|
+
t.add_argument("--settings", help="settings file (.json or .toml)")
|
|
35
|
+
t.add_argument("--qubits", type=int, help="num_qubits")
|
|
36
|
+
t.add_argument("--layers", type=int, help="train layers 1 to N (min_layers=1, max_layers=N)")
|
|
37
|
+
t.add_argument("--seed", type=int, help="seed for the initial parameters")
|
|
38
|
+
t.add_argument("--backend", help="where the circuits run (default: statevector)")
|
|
39
|
+
t.add_argument("--task", choices=["classification", "regression"],
|
|
40
|
+
help="default: detected from the target column")
|
|
41
|
+
t.add_argument("--test-size", type=float,
|
|
42
|
+
help="held-out test rows, a fraction or a count (0 = train on all rows)")
|
|
43
|
+
|
|
44
|
+
e = sub.add_parser("evaluate", help="score a model on its held-out test rows")
|
|
45
|
+
e.add_argument("train_id")
|
|
46
|
+
e.add_argument("--layer", type=int)
|
|
47
|
+
e.add_argument("--backend")
|
|
48
|
+
|
|
49
|
+
r = sub.add_parser("predict", help="predictions for new rows")
|
|
50
|
+
r.add_argument("train_id")
|
|
51
|
+
r.add_argument("data", help="CSV file with the new rows")
|
|
52
|
+
r.add_argument("-o", "--output", help="write the rows and predictions to this CSV file")
|
|
53
|
+
r.add_argument("--layer", type=int)
|
|
54
|
+
r.add_argument("--backend")
|
|
55
|
+
|
|
56
|
+
sub.add_parser("models", help="list recently trained models")
|
|
57
|
+
sub.add_parser("status", help="check the server and show the server and SDK versions")
|
|
58
|
+
|
|
59
|
+
args = p.parse_args(argv)
|
|
60
|
+
client = connect(url=args.url, api_key=args.api_key, local=args.local or False)
|
|
61
|
+
verbose = False if args.quiet else "log" if args.log else True
|
|
62
|
+
run_settings = {"backend": args.backend} if getattr(args, "backend", None) else None
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
if args.command == "train":
|
|
66
|
+
from .settings import load_settings
|
|
67
|
+
settings = load_settings(args.settings)
|
|
68
|
+
if args.qubits is not None:
|
|
69
|
+
settings["num_qubits"] = args.qubits
|
|
70
|
+
if args.layers is not None:
|
|
71
|
+
settings["min_layers"], settings["max_layers"] = 1, args.layers
|
|
72
|
+
if args.seed is not None:
|
|
73
|
+
settings["seed"] = args.seed
|
|
74
|
+
if args.backend:
|
|
75
|
+
settings["backend"] = args.backend
|
|
76
|
+
test_size = args.test_size
|
|
77
|
+
if test_size is not None and test_size >= 1:
|
|
78
|
+
test_size = int(test_size)
|
|
79
|
+
result = client.training.run(args.data, settings=settings, verbose=verbose,
|
|
80
|
+
task=args.task, test_size=test_size)
|
|
81
|
+
for layer, v in result.get("validation", {}).items():
|
|
82
|
+
if v.get("test_accuracy") is not None:
|
|
83
|
+
print(f"Layer {layer}: test accuracy {v['test_accuracy']:.1%}")
|
|
84
|
+
elif v.get("test_loss") is not None:
|
|
85
|
+
print(f"Layer {layer}: test loss {v['test_loss']:.4f}")
|
|
86
|
+
else:
|
|
87
|
+
print(f"Layer {layer}: trained on all rows (no test set)")
|
|
88
|
+
print(f"train_id: {result['train_id']}")
|
|
89
|
+
|
|
90
|
+
elif args.command == "evaluate":
|
|
91
|
+
report = client.training.evaluate(args.train_id, layer=args.layer, verbose=verbose,
|
|
92
|
+
settings=run_settings)
|
|
93
|
+
if report.get("task") == "regression":
|
|
94
|
+
print(f"Mean absolute error {report['mean_absolute_error']:.4f}, "
|
|
95
|
+
f"loss {report['overall_loss']:.4f} on {report['test_samples']} test rows")
|
|
96
|
+
else:
|
|
97
|
+
print(f"Accuracy {report['overall_accuracy']:.1%} on {report['test_samples']} test rows")
|
|
98
|
+
for c in report["per_class"]:
|
|
99
|
+
print(f" class {c['class']}: {c['accuracy']:.1%} ({c['samples']} rows)")
|
|
100
|
+
|
|
101
|
+
elif args.command == "predict":
|
|
102
|
+
result = client.inference.run(args.train_id, args.data, layer=args.layer,
|
|
103
|
+
output=args.output, verbose=verbose,
|
|
104
|
+
settings=run_settings)
|
|
105
|
+
if args.output:
|
|
106
|
+
print(f"{result['rows']} predictions written to {args.output}")
|
|
107
|
+
else:
|
|
108
|
+
for p_row in result["predictions"]:
|
|
109
|
+
print(p_row["prediction"])
|
|
110
|
+
if "accuracy" in result:
|
|
111
|
+
print(f"Accuracy on these rows: {result['accuracy']:.1%}")
|
|
112
|
+
if "mean_absolute_error" in result:
|
|
113
|
+
print(f"Mean absolute error on these rows: {result['mean_absolute_error']:.4f}")
|
|
114
|
+
|
|
115
|
+
elif args.command == "models":
|
|
116
|
+
for m in client.inference.models()["models"]:
|
|
117
|
+
print(m["train_id"], m.get("dataset_display_name") or m.get("dataset", ""),
|
|
118
|
+
m.get("task", ""), f"{m.get('num_qubits')} qubits", f"layers {m.get('layers')}")
|
|
119
|
+
|
|
120
|
+
elif args.command == "status":
|
|
121
|
+
s = client.status()
|
|
122
|
+
print(f"Server: {client.url or 'none (local mode: the Red Cedar core in this process)'}")
|
|
123
|
+
print(f"Server version: {s['server_version'] or 'unknown'}")
|
|
124
|
+
print(f"SDK version: {s['sdk_version']}")
|
|
125
|
+
if not s["ok"]:
|
|
126
|
+
print(f"Error: {s['error']}", file=sys.stderr)
|
|
127
|
+
return 1
|
|
128
|
+
|
|
129
|
+
except (RuntimeError, requests.HTTPError, requests.ConnectionError, FileNotFoundError) as err:
|
|
130
|
+
print(f"Error: {err}", file=sys.stderr)
|
|
131
|
+
return 1
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
sys.exit(main())
|
redcedar/client.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Core client — HTTP requests, SSE streaming, authentication."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import threading
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from .datasets import DatasetManager
|
|
11
|
+
from .analysis import AnalysisManager
|
|
12
|
+
from .training import TrainingManager
|
|
13
|
+
from .inference import InferenceManager
|
|
14
|
+
from .explainable import ExplainableManager
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def connect(url=None, api_key=None, local=False):
|
|
18
|
+
"""Create a RedCedarClient connected to the server.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
url: Server URL. Defaults to REDCEDAR_URL env var or http://127.0.0.1:8002
|
|
22
|
+
(the port run_server uses).
|
|
23
|
+
api_key: API key. Defaults to REDCEDAR_API_KEY env var.
|
|
24
|
+
local: For development with the Red Cedar source. True calls the Red Cedar
|
|
25
|
+
core (redcedar_core, installed with `pip install -e .` in a
|
|
26
|
+
red-cedar folder) directly in this process, so a debugger steps into
|
|
27
|
+
the Red Cedar code. Or the path of a red-cedar folder to use.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
RedCedarClient instance.
|
|
31
|
+
"""
|
|
32
|
+
if local:
|
|
33
|
+
from .local import import_core
|
|
34
|
+
return RedCedarClient(None, None, core=import_core(None if local is True else local))
|
|
35
|
+
url = url or os.environ.get("REDCEDAR_URL", "http://127.0.0.1:8002")
|
|
36
|
+
api_key = api_key or os.environ.get("REDCEDAR_API_KEY", "")
|
|
37
|
+
return RedCedarClient(url, api_key)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _raise_for_status(resp):
|
|
41
|
+
"""Like resp.raise_for_status(), but the message includes the server's reason
|
|
42
|
+
('detail' from the Red Cedar server, 'error' from the gateway)."""
|
|
43
|
+
if resp.ok:
|
|
44
|
+
return
|
|
45
|
+
try:
|
|
46
|
+
body = resp.json()
|
|
47
|
+
reason = body.get("detail") or body.get("error") if isinstance(body, dict) else None
|
|
48
|
+
except ValueError:
|
|
49
|
+
reason = None
|
|
50
|
+
msg = f"{resp.status_code} {resp.reason}"
|
|
51
|
+
if reason:
|
|
52
|
+
msg += f": {reason}"
|
|
53
|
+
raise requests.HTTPError(msg, response=resp)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _ProgressLine:
|
|
57
|
+
"""A job's verbose output: one line, rewritten on each progress update, with a dot
|
|
58
|
+
added while the job is quiet so it's clear it's still working. The dots come every
|
|
59
|
+
2 seconds at first, every 5 after 20 seconds and every 10 after a minute, counted
|
|
60
|
+
from the last update."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, out=None):
|
|
63
|
+
self._out = out or sys.stdout
|
|
64
|
+
self._lock = threading.Lock()
|
|
65
|
+
self._text = ""
|
|
66
|
+
self._ndots = 0
|
|
67
|
+
self._width = 0 # characters on the current line
|
|
68
|
+
self._restart()
|
|
69
|
+
self._stop = threading.Event()
|
|
70
|
+
threading.Thread(target=self._dots, daemon=True).start()
|
|
71
|
+
|
|
72
|
+
def _restart(self):
|
|
73
|
+
self._since = time.monotonic()
|
|
74
|
+
self._next = self._since + 2
|
|
75
|
+
|
|
76
|
+
def _draw(self):
|
|
77
|
+
"""Write the whole line again, text and dots. Jupyter treats \\r as overwriting
|
|
78
|
+
the line from its start, so the line is always redrawn rather than added to;
|
|
79
|
+
trailing spaces clear what's left of a longer line."""
|
|
80
|
+
s = self._text + (" " + "." * self._ndots if self._ndots else "")
|
|
81
|
+
print(f"\r{s}{' ' * max(self._width - len(s), 0)}", end="", flush=True, file=self._out)
|
|
82
|
+
self._width = len(s)
|
|
83
|
+
|
|
84
|
+
def _dots(self):
|
|
85
|
+
while not self._stop.wait(0.5):
|
|
86
|
+
with self._lock:
|
|
87
|
+
now = time.monotonic()
|
|
88
|
+
if now >= self._next:
|
|
89
|
+
self._ndots += 1
|
|
90
|
+
self._draw()
|
|
91
|
+
quiet = now - self._since
|
|
92
|
+
self._next = now + (2 if quiet < 20 else 5 if quiet < 60 else 10)
|
|
93
|
+
|
|
94
|
+
def update(self, text):
|
|
95
|
+
"""Rewrite the line with new text; the dots start again."""
|
|
96
|
+
with self._lock:
|
|
97
|
+
self._text, self._ndots = text, 0
|
|
98
|
+
self._draw()
|
|
99
|
+
self._restart()
|
|
100
|
+
|
|
101
|
+
def message(self, text):
|
|
102
|
+
"""A line of its own (a log line, an error), below the progress line."""
|
|
103
|
+
with self._lock:
|
|
104
|
+
if self._width:
|
|
105
|
+
print(file=self._out)
|
|
106
|
+
print(text, flush=True, file=self._out)
|
|
107
|
+
self._text, self._ndots, self._width = "", 0, 0
|
|
108
|
+
self._restart()
|
|
109
|
+
|
|
110
|
+
def close(self):
|
|
111
|
+
self._stop.set()
|
|
112
|
+
with self._lock:
|
|
113
|
+
if self._width:
|
|
114
|
+
print(file=self._out)
|
|
115
|
+
self._text, self._ndots, self._width = "", 0, 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class RedCedarClient:
|
|
119
|
+
"""Main client for interacting with the Red Cedar server."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, url, api_key, core=None):
|
|
122
|
+
self.url = url.rstrip("/") if url else None
|
|
123
|
+
self.api_key = api_key
|
|
124
|
+
# Local mode (connect(local=True)): the redcedar_core module. Each SDK call then
|
|
125
|
+
# calls its core function directly instead of sending a request to a server.
|
|
126
|
+
self.core = core
|
|
127
|
+
self._last_job_id = None
|
|
128
|
+
self._server_version = None
|
|
129
|
+
self.datasets = DatasetManager(self)
|
|
130
|
+
self.analysis = AnalysisManager(self)
|
|
131
|
+
self.training = TrainingManager(self)
|
|
132
|
+
self.inference = InferenceManager(self)
|
|
133
|
+
self.explainable = ExplainableManager(self)
|
|
134
|
+
|
|
135
|
+
def _server(self, path=""):
|
|
136
|
+
"""The server URL. Local mode has no server: every SDK call has a direct one."""
|
|
137
|
+
if self.url is None:
|
|
138
|
+
raise NotImplementedError(f"Local mode has no direct call for {path}")
|
|
139
|
+
return self.url
|
|
140
|
+
|
|
141
|
+
def _headers(self):
|
|
142
|
+
h = {"Content-Type": "application/json"}
|
|
143
|
+
if self.api_key:
|
|
144
|
+
h["x-api-key"] = self.api_key
|
|
145
|
+
return h
|
|
146
|
+
|
|
147
|
+
def _local_call(self, fn, *args, **kwargs):
|
|
148
|
+
"""Call a redcedar_core function (local mode). Something not found, or a bad
|
|
149
|
+
request, raises requests.HTTPError with the server's message, as in server mode."""
|
|
150
|
+
try:
|
|
151
|
+
return fn(*args, **kwargs)
|
|
152
|
+
except FileNotFoundError as e:
|
|
153
|
+
raise requests.HTTPError(f"404 Not Found: {e}") from e
|
|
154
|
+
except ValueError as e:
|
|
155
|
+
raise requests.HTTPError(f"400 Bad Request: {e}") from e
|
|
156
|
+
|
|
157
|
+
def _run_local(self, fn, *args, verbose=False, on_progress=None, events=None, **kwargs):
|
|
158
|
+
"""Run a redcedar_core job function in this thread (local mode). Its events go
|
|
159
|
+
to the same handler as a server job's: progress, log lines (verbose="log"), and the
|
|
160
|
+
other events into the events list. Returns the job's result."""
|
|
161
|
+
out = sys.stdout # while the job runs, print() goes to its log
|
|
162
|
+
line = _ProgressLine(out) if verbose else None
|
|
163
|
+
on_event = lambda e: self._handle_event(e, verbose, on_progress, events, line) # noqa: E731
|
|
164
|
+
try:
|
|
165
|
+
return self._local_call(fn, *args, on_event=on_event, **kwargs)
|
|
166
|
+
finally:
|
|
167
|
+
if line:
|
|
168
|
+
line.close()
|
|
169
|
+
|
|
170
|
+
def _get(self, path):
|
|
171
|
+
resp = requests.get(f"{self._server(path)}{path}", headers=self._headers())
|
|
172
|
+
_raise_for_status(resp)
|
|
173
|
+
return resp.json()
|
|
174
|
+
|
|
175
|
+
def _post(self, path, body=None):
|
|
176
|
+
resp = requests.post(
|
|
177
|
+
f"{self._server(path)}{path}",
|
|
178
|
+
headers=self._headers(),
|
|
179
|
+
json=body or {},
|
|
180
|
+
)
|
|
181
|
+
_raise_for_status(resp)
|
|
182
|
+
return resp.json()
|
|
183
|
+
|
|
184
|
+
def _post_file(self, path, file_path):
|
|
185
|
+
"""Upload a file as multipart form data (field name 'file')."""
|
|
186
|
+
headers = {"x-api-key": self.api_key} if self.api_key else {}
|
|
187
|
+
with open(file_path, "rb") as f:
|
|
188
|
+
resp = requests.post(
|
|
189
|
+
f"{self._server(path)}{path}",
|
|
190
|
+
headers=headers,
|
|
191
|
+
files={"file": (os.path.basename(file_path), f)},
|
|
192
|
+
)
|
|
193
|
+
_raise_for_status(resp)
|
|
194
|
+
return resp.json()
|
|
195
|
+
|
|
196
|
+
def _open_stream(self, path, line=None):
|
|
197
|
+
"""Open a job's SSE stream. A queued job has no stream until it starts
|
|
198
|
+
(the server answers 404), so wait for it to start."""
|
|
199
|
+
waiting = False
|
|
200
|
+
while True:
|
|
201
|
+
resp = requests.get(
|
|
202
|
+
f"{self._server(path)}{path}",
|
|
203
|
+
headers=self._headers(),
|
|
204
|
+
stream=True,
|
|
205
|
+
)
|
|
206
|
+
if resp.status_code != 404:
|
|
207
|
+
_raise_for_status(resp)
|
|
208
|
+
return resp
|
|
209
|
+
resp.close()
|
|
210
|
+
|
|
211
|
+
# Also launches the next queued job on servers that only do so on a request.
|
|
212
|
+
qs = self._get("/jobs/status")
|
|
213
|
+
if not qs.get("total"):
|
|
214
|
+
# Nothing queued or running, so the job is gone
|
|
215
|
+
_raise_for_status(resp)
|
|
216
|
+
if line and not waiting:
|
|
217
|
+
line.update(" Waiting in queue...")
|
|
218
|
+
waiting = True
|
|
219
|
+
time.sleep(2)
|
|
220
|
+
|
|
221
|
+
def _stream_sse(self, path, line=None):
|
|
222
|
+
"""Consume an SSE stream, yielding parsed events."""
|
|
223
|
+
resp = self._open_stream(path, line)
|
|
224
|
+
|
|
225
|
+
event_type = "message"
|
|
226
|
+
data_buf = ""
|
|
227
|
+
|
|
228
|
+
for line in resp.iter_lines(decode_unicode=True):
|
|
229
|
+
if line is None:
|
|
230
|
+
continue
|
|
231
|
+
if line == "":
|
|
232
|
+
# End of event
|
|
233
|
+
if data_buf:
|
|
234
|
+
try:
|
|
235
|
+
parsed = json.loads(data_buf)
|
|
236
|
+
parsed["_event"] = event_type
|
|
237
|
+
yield parsed
|
|
238
|
+
except json.JSONDecodeError:
|
|
239
|
+
pass
|
|
240
|
+
event_type = "message"
|
|
241
|
+
data_buf = ""
|
|
242
|
+
elif line.startswith("event: "):
|
|
243
|
+
event_type = line[7:]
|
|
244
|
+
elif line.startswith("data: "):
|
|
245
|
+
data_buf = line[6:]
|
|
246
|
+
|
|
247
|
+
def _run_job(self, post_path, stream_path, body=None, verbose=False, on_progress=None,
|
|
248
|
+
events=None):
|
|
249
|
+
"""Start a job via POST, consume SSE until done. Returns summary.
|
|
250
|
+
|
|
251
|
+
If events is a list, every other event the job sends (e.g. training
|
|
252
|
+
datapoints and validation results) is appended to it."""
|
|
253
|
+
result = self._post(post_path, body)
|
|
254
|
+
job_id = result.get("job_id") or result.get("train_id")
|
|
255
|
+
self._last_job_id = job_id
|
|
256
|
+
if not job_id:
|
|
257
|
+
return result
|
|
258
|
+
|
|
259
|
+
# Trigger server-side queue cleanup and job launch before connecting
|
|
260
|
+
# to SSE. Without this, stale queue entries from prior jobs block
|
|
261
|
+
# new jobs from starting and training_state is never populated.
|
|
262
|
+
try:
|
|
263
|
+
self._get("/jobs/status")
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
line = _ProgressLine() if verbose else None
|
|
268
|
+
try:
|
|
269
|
+
for event in self._stream_sse(stream_path.format(job_id=job_id), line):
|
|
270
|
+
self._handle_event(event, verbose, on_progress, events, line)
|
|
271
|
+
etype = event.get("_event", event.get("type", ""))
|
|
272
|
+
if etype == "done":
|
|
273
|
+
return event.get("summary", event)
|
|
274
|
+
elif etype == "error":
|
|
275
|
+
raise RuntimeError(event.get("message", "Job failed"))
|
|
276
|
+
elif etype == "stopped":
|
|
277
|
+
raise RuntimeError(event.get("message", "Job stopped"))
|
|
278
|
+
finally:
|
|
279
|
+
if line:
|
|
280
|
+
line.close()
|
|
281
|
+
|
|
282
|
+
raise RuntimeError("SSE stream ended without done/error event")
|
|
283
|
+
|
|
284
|
+
def _handle_event(self, event, verbose, on_progress, events, line=None):
|
|
285
|
+
"""Report one job event: progress and messages (verbose; the server's log lines
|
|
286
|
+
only with verbose="log"), the on_progress callback, and the other events into
|
|
287
|
+
the events list. line is the job's _ProgressLine when verbose."""
|
|
288
|
+
etype = event.get("_event", event.get("type", ""))
|
|
289
|
+
|
|
290
|
+
if etype == "progress":
|
|
291
|
+
pct = event.get("percent", 0)
|
|
292
|
+
msg = event.get("message", "")
|
|
293
|
+
if verbose:
|
|
294
|
+
line.update(f" [{pct:3d}%] {msg}")
|
|
295
|
+
if on_progress:
|
|
296
|
+
on_progress(pct, msg)
|
|
297
|
+
|
|
298
|
+
elif etype == "done":
|
|
299
|
+
if verbose:
|
|
300
|
+
line.update(" [100%] Done.")
|
|
301
|
+
line.close()
|
|
302
|
+
|
|
303
|
+
elif etype == "log":
|
|
304
|
+
if verbose == "log":
|
|
305
|
+
msg = event.get("message", "")
|
|
306
|
+
if msg:
|
|
307
|
+
line.message(f" {msg}")
|
|
308
|
+
|
|
309
|
+
elif etype == "error":
|
|
310
|
+
if verbose:
|
|
311
|
+
line.message(f" ERROR: {event.get('message', 'Job failed')}")
|
|
312
|
+
|
|
313
|
+
elif etype == "stopped":
|
|
314
|
+
if verbose:
|
|
315
|
+
line.message(f" {event.get('message', 'Job stopped')}")
|
|
316
|
+
|
|
317
|
+
else:
|
|
318
|
+
# A training layer's test result
|
|
319
|
+
if etype == "validation" and verbose:
|
|
320
|
+
acc, loss = event.get("test_accuracy"), event.get("test_loss")
|
|
321
|
+
if acc is not None:
|
|
322
|
+
line.message(f" Layer {event.get('layer')}: test accuracy {acc:.1%}")
|
|
323
|
+
elif loss is not None:
|
|
324
|
+
line.message(f" Layer {event.get('layer')}: test loss {loss:.4f}")
|
|
325
|
+
if events is not None:
|
|
326
|
+
events.append(event)
|
|
327
|
+
|
|
328
|
+
def status(self):
|
|
329
|
+
"""Check the server, and which versions are in use.
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
Dict with 'ok', 'server_version' (None if the server doesn't report
|
|
333
|
+
one) and 'sdk_version' (this package's version; its major.minor
|
|
334
|
+
follows the server's), plus 'error' when 'ok' is False. In local
|
|
335
|
+
mode, server_version is the version of the Red Cedar code in use.
|
|
336
|
+
"""
|
|
337
|
+
from . import __version__
|
|
338
|
+
if self.core:
|
|
339
|
+
return {"ok": True, "server_version": self.core.version(), "sdk_version": __version__}
|
|
340
|
+
try:
|
|
341
|
+
resp = requests.get(f"{self._server('/health')}/health", headers=self._headers(), timeout=5)
|
|
342
|
+
except requests.ConnectionError:
|
|
343
|
+
return {"ok": False, "server_version": None, "sdk_version": __version__,
|
|
344
|
+
"error": "Cannot reach server"}
|
|
345
|
+
try:
|
|
346
|
+
body = resp.json()
|
|
347
|
+
except ValueError:
|
|
348
|
+
body = {}
|
|
349
|
+
if not isinstance(body, dict):
|
|
350
|
+
body = {}
|
|
351
|
+
# Through the gateway (/api/health) the server's answer is under "backend"
|
|
352
|
+
server = body.get("backend", body)
|
|
353
|
+
if not isinstance(server, dict):
|
|
354
|
+
server = {}
|
|
355
|
+
ok = resp.status_code == 200 and server.get("status") == "ok"
|
|
356
|
+
result = {"ok": ok, "server_version": server.get("version"), "sdk_version": __version__}
|
|
357
|
+
if not ok:
|
|
358
|
+
result["error"] = server.get("error") or f"HTTP {resp.status_code}"
|
|
359
|
+
return result
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def server_version(self):
|
|
363
|
+
"""The version of the server this client is connected to (asked once)."""
|
|
364
|
+
if self._server_version is None:
|
|
365
|
+
self._server_version = self.status().get("server_version")
|
|
366
|
+
return self._server_version
|
redcedar/datasets.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Dataset listing and selection."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DatasetManager:
|
|
7
|
+
"""Access the dataset catalog on the server."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, client):
|
|
10
|
+
self._client = client
|
|
11
|
+
|
|
12
|
+
def list(self):
|
|
13
|
+
"""List all available datasets in the catalog.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
List of dataset dicts with id, name, description, rows, columns, classes.
|
|
17
|
+
"""
|
|
18
|
+
if self._client.core: # local mode: call the core directly
|
|
19
|
+
return self._client.core.datasets()
|
|
20
|
+
return self._client._get("/datasets")
|
|
21
|
+
|
|
22
|
+
def upload(self, file_path):
|
|
23
|
+
"""Upload a CSV file to the server for training.
|
|
24
|
+
|
|
25
|
+
The last column is the target (class label); a header row is optional.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
file_path: Path to a local .csv file.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
The dataset ID to pass to training.run().
|
|
32
|
+
"""
|
|
33
|
+
if self._client.core:
|
|
34
|
+
return self._client._local_call(self._client.core.upload, file_path)
|
|
35
|
+
return self._client._post_file("/upload", file_path)["jobId"]
|
|
36
|
+
|
|
37
|
+
def _as_id(self, data):
|
|
38
|
+
"""A CSV file path is uploaded and replaced by its dataset ID; anything
|
|
39
|
+
else (a catalog ID or an upload ID) is returned as it is."""
|
|
40
|
+
if isinstance(data, os.PathLike) or (isinstance(data, str) and os.path.isfile(data)):
|
|
41
|
+
return self.upload(os.fspath(data))
|
|
42
|
+
# IDs never end in .csv, so this is a file path that doesn't exist
|
|
43
|
+
if isinstance(data, str) and data.lower().endswith(".csv"):
|
|
44
|
+
raise FileNotFoundError(f"No such file: {data}")
|
|
45
|
+
return data
|
redcedar/explainable.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Explainable AI — identify which features drive classification."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ExplainableManager:
|
|
5
|
+
"""Run explainable AI analysis on trained models."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, client):
|
|
8
|
+
self._client = client
|
|
9
|
+
|
|
10
|
+
def run(self, train_id, verbose=False, on_progress=None, settings=None):
|
|
11
|
+
"""Run explainable AI analysis on a trained model.
|
|
12
|
+
|
|
13
|
+
Constructs quantum circuits to identify which input
|
|
14
|
+
features are most important for classification decisions.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
train_id: Training job ID (from training.run() result).
|
|
18
|
+
verbose: Print progress to stdout; "log" also prints the server's log for the job.
|
|
19
|
+
on_progress: Callback(percent, message) for progress updates.
|
|
20
|
+
settings: Optional dict of runtime overrides (e.g. backend, parallelize).
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Dict with qubit_map (feature importance per qubit),
|
|
24
|
+
distributions, convergence values, feature_indices, etc.
|
|
25
|
+
"""
|
|
26
|
+
if self._client.core: # local mode: call the core directly
|
|
27
|
+
return self._client._run_local(self._client.core.explain, train_id, settings=settings,
|
|
28
|
+
verbose=verbose, on_progress=on_progress)
|
|
29
|
+
body = {"train_id": train_id, "user_id": "sdk"}
|
|
30
|
+
if settings:
|
|
31
|
+
body["settings"] = settings
|
|
32
|
+
return self._client._run_job(
|
|
33
|
+
"/applications/explainable",
|
|
34
|
+
"/applications/explainable/{job_id}/stream",
|
|
35
|
+
body=body,
|
|
36
|
+
verbose=verbose,
|
|
37
|
+
on_progress=on_progress,
|
|
38
|
+
)
|