cattykit 0.1.1__tar.gz → 0.1.2__tar.gz
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.
- {cattykit-0.1.1 → cattykit-0.1.2}/PKG-INFO +8 -1
- {cattykit-0.1.1 → cattykit-0.1.2}/README.md +6 -0
- cattykit-0.1.2/cattykit/cli.py +28 -0
- cattykit-0.1.2/cattykit/experiments.py +602 -0
- {cattykit-0.1.1 → cattykit-0.1.2}/cattykit/logging/__init__.py +2 -3
- cattykit-0.1.1/cattykit/logging/sqlite_logger.py → cattykit-0.1.2/cattykit/logging/cattycam_logger.py +258 -129
- cattykit-0.1.2/cattykit/logging/identifiers.py +209 -0
- {cattykit-0.1.1 → cattykit-0.1.2}/cattykit/logging/model_logger.py +1 -4
- cattykit-0.1.2/cattykit/logging/null_logger.py +16 -0
- cattykit-0.1.2/cattykit/logging/print_logger.py +36 -0
- cattykit-0.1.2/cattykit/models/loading.py +314 -0
- {cattykit-0.1.1 → cattykit-0.1.2}/cattykit.egg-info/PKG-INFO +8 -1
- {cattykit-0.1.1 → cattykit-0.1.2}/cattykit.egg-info/SOURCES.txt +4 -2
- cattykit-0.1.2/cattykit.egg-info/entry_points.txt +2 -0
- cattykit-0.1.2/cattykit.egg-info/requires.txt +2 -0
- {cattykit-0.1.1 → cattykit-0.1.2}/pyproject.toml +5 -1
- cattykit-0.1.1/cattykit/experiments.py +0 -99
- cattykit-0.1.1/cattykit/logging/event.py +0 -32
- cattykit-0.1.1/cattykit/logging/null_logger.py +0 -13
- cattykit-0.1.1/cattykit/logging/print_logger.py +0 -24
- cattykit-0.1.1/cattykit/models/loading.py +0 -186
- cattykit-0.1.1/cattykit.egg-info/requires.txt +0 -1
- {cattykit-0.1.1 → cattykit-0.1.2}/LICENSE +0 -0
- {cattykit-0.1.1 → cattykit-0.1.2}/cattykit/__init__.py +0 -0
- {cattykit-0.1.1 → cattykit-0.1.2}/cattykit/models/__init__.py +0 -0
- {cattykit-0.1.1 → cattykit-0.1.2}/cattykit/models/protocol.py +0 -0
- {cattykit-0.1.1 → cattykit-0.1.2}/cattykit/models/registry.py +0 -0
- {cattykit-0.1.1 → cattykit-0.1.2}/cattykit/py.typed +0 -0
- {cattykit-0.1.1 → cattykit-0.1.2}/cattykit.egg-info/dependency_links.txt +0 -0
- {cattykit-0.1.1 → cattykit-0.1.2}/cattykit.egg-info/top_level.txt +0 -0
- {cattykit-0.1.1 → cattykit-0.1.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cattykit
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: A framework for implementing artificial intelligence models using components from the cognitive models of the Fluid Analogies Research Group
|
|
5
5
|
Author-email: George Alexander Wright <me@georgeaw.com>
|
|
6
6
|
License: GNU AFFERO GENERAL PUBLIC LICENSE
|
|
@@ -672,6 +672,7 @@ Requires-Python: >=3.12
|
|
|
672
672
|
Description-Content-Type: text/markdown
|
|
673
673
|
License-File: LICENSE
|
|
674
674
|
Requires-Dist: numpy>=2.4.3
|
|
675
|
+
Requires-Dist: pandas>=3.0.5
|
|
675
676
|
Dynamic: license-file
|
|
676
677
|
|
|
677
678
|
# Cattykit
|
|
@@ -707,6 +708,12 @@ print(cattykit.available_models())
|
|
|
707
708
|
cattykit.install_model("path/to/model")
|
|
708
709
|
```
|
|
709
710
|
|
|
711
|
+
The same installation is available from the command line:
|
|
712
|
+
|
|
713
|
+
```bash
|
|
714
|
+
cattykit install copycat --version 0.1.0
|
|
715
|
+
```
|
|
716
|
+
|
|
710
717
|
Once you have installed a model, you can ask it to solve a problem.
|
|
711
718
|
|
|
712
719
|
```python
|
|
@@ -31,6 +31,12 @@ print(cattykit.available_models())
|
|
|
31
31
|
cattykit.install_model("path/to/model")
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
The same installation is available from the command line:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
cattykit install copycat --version 0.1.0
|
|
38
|
+
```
|
|
39
|
+
|
|
34
40
|
Once you have installed a model, you can ask it to solve a problem.
|
|
35
41
|
|
|
36
42
|
```python
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
|
|
6
|
+
from .models import ModelInstallationError, ModelSourceError, install_model
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
10
|
+
"""Run the CattyKit command-line interface."""
|
|
11
|
+
parser = argparse.ArgumentParser(prog="cattykit")
|
|
12
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
13
|
+
|
|
14
|
+
install_parser = commands.add_parser("install", help="Install a model.")
|
|
15
|
+
install_parser.add_argument(
|
|
16
|
+
"source", help="Official model name, HTTPS URL, or local path."
|
|
17
|
+
)
|
|
18
|
+
install_parser.add_argument(
|
|
19
|
+
"--version",
|
|
20
|
+
help="Official model version to install.",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
args = parser.parse_args(argv)
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
install_model(args.source, version=args.version)
|
|
27
|
+
except (ModelInstallationError, ModelSourceError) as error:
|
|
28
|
+
parser.error(str(error))
|
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from math import erfc, exp, inf, isfinite, lgamma, log, sqrt
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from shutil import get_terminal_size
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from .logging import CattycamLogger, NullLogger
|
|
9
|
+
from .models import load_model
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SUMMARY_COLUMNS = [
|
|
13
|
+
"problem",
|
|
14
|
+
"solution",
|
|
15
|
+
"frequency",
|
|
16
|
+
"mean_codelets_run",
|
|
17
|
+
"codelets_standard_error",
|
|
18
|
+
"mean_temperature",
|
|
19
|
+
"temperature_standard_error",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_experiment(
|
|
24
|
+
model_name: str,
|
|
25
|
+
problems: list[str],
|
|
26
|
+
iterations: int,
|
|
27
|
+
logging_db: str | Path | None = None,
|
|
28
|
+
verbose: bool = True,
|
|
29
|
+
) -> pd.DataFrame:
|
|
30
|
+
"""Run the model repeatedly on each problem.
|
|
31
|
+
The iteration count is used as the random seed given to the model, so the
|
|
32
|
+
same random seeds are used for each problem.
|
|
33
|
+
Returns one results DataFrame containing one row per solution plus a
|
|
34
|
+
totals row for each problem.
|
|
35
|
+
"""
|
|
36
|
+
interactive = verbose and sys.stdout.isatty()
|
|
37
|
+
result_frames: list[pd.DataFrame] = []
|
|
38
|
+
logger = (
|
|
39
|
+
CattycamLogger(logging_db, model_name)
|
|
40
|
+
if logging_db is not None
|
|
41
|
+
else NullLogger(model_name)
|
|
42
|
+
)
|
|
43
|
+
try:
|
|
44
|
+
for problem_number, problem in enumerate(problems):
|
|
45
|
+
if interactive:
|
|
46
|
+
if problem_number:
|
|
47
|
+
print(_terminal_rule("="))
|
|
48
|
+
print(problem)
|
|
49
|
+
print(_terminal_rule("-"))
|
|
50
|
+
display = RunningAnswerDisplay()
|
|
51
|
+
runs: list[dict[str, str | float | int | None]] = []
|
|
52
|
+
for seed in range(iterations):
|
|
53
|
+
model = load_model(
|
|
54
|
+
model_name,
|
|
55
|
+
config={"seed": seed},
|
|
56
|
+
logger=logger,
|
|
57
|
+
)
|
|
58
|
+
try:
|
|
59
|
+
answer = model.solve(problem)
|
|
60
|
+
runs.append(
|
|
61
|
+
{
|
|
62
|
+
"solution": answer,
|
|
63
|
+
"codelets_run": model.coderack.number_of_codelets_run,
|
|
64
|
+
"temperature": model.temperature,
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
if interactive:
|
|
68
|
+
display.update(summarize_runs(runs, problem))
|
|
69
|
+
finally:
|
|
70
|
+
model.close()
|
|
71
|
+
result_frames.append(summarize_runs(runs, problem))
|
|
72
|
+
finally:
|
|
73
|
+
logger.close()
|
|
74
|
+
if not result_frames:
|
|
75
|
+
return pd.DataFrame(columns=SUMMARY_COLUMNS)
|
|
76
|
+
return pd.concat(result_frames, ignore_index=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def run_reproduction(
|
|
80
|
+
model_name: str,
|
|
81
|
+
gold_behaviour: dict[str, pd.DataFrame | list[dict]],
|
|
82
|
+
iterations: int,
|
|
83
|
+
verbose: bool = True,
|
|
84
|
+
output_file: str | None = None,
|
|
85
|
+
) -> tuple[pd.DataFrame, dict[str, object]]:
|
|
86
|
+
"""Compare current model behaviour with previously published behaviour.
|
|
87
|
+
``gold_behaviour`` maps each problem to a summary table with the same
|
|
88
|
+
columns produced by ``summarize_runs``, except for ``problem``.
|
|
89
|
+
The returned table has paired observed and expected columns for every
|
|
90
|
+
expected solution and totals row. Differences are observed minus expected.
|
|
91
|
+
"""
|
|
92
|
+
problems = list(gold_behaviour.keys())
|
|
93
|
+
observed = run_experiment(
|
|
94
|
+
model_name,
|
|
95
|
+
problems,
|
|
96
|
+
iterations,
|
|
97
|
+
verbose=False,
|
|
98
|
+
)
|
|
99
|
+
gold = _gold_behaviour_dataframe(gold_behaviour)
|
|
100
|
+
comparison_rows: list[dict[str, str | int | float | None]] = []
|
|
101
|
+
for problem in problems:
|
|
102
|
+
observed_problem = observed[observed["problem"] == problem]
|
|
103
|
+
gold_problem = gold[gold["problem"] == problem]
|
|
104
|
+
observed_solutions = observed_problem[observed_problem["solution"] != "Total"]
|
|
105
|
+
gold_solutions = gold_problem[gold_problem["solution"] != "Total"]
|
|
106
|
+
observed_distribution = dict(
|
|
107
|
+
zip(
|
|
108
|
+
observed_solutions["solution"],
|
|
109
|
+
observed_solutions["frequency"],
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
gold_distribution = dict(
|
|
113
|
+
zip(
|
|
114
|
+
gold_solutions["solution"],
|
|
115
|
+
gold_solutions["frequency"],
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
tv_distance = total_variation_distance(
|
|
119
|
+
observed_distribution,
|
|
120
|
+
gold_distribution,
|
|
121
|
+
)
|
|
122
|
+
observed_total = _total_row(
|
|
123
|
+
observed_problem,
|
|
124
|
+
problem,
|
|
125
|
+
"observed",
|
|
126
|
+
)
|
|
127
|
+
gold_total = _total_row(
|
|
128
|
+
gold_problem,
|
|
129
|
+
problem,
|
|
130
|
+
"gold",
|
|
131
|
+
)
|
|
132
|
+
codelets_z = z_statistic(
|
|
133
|
+
observed_mean=observed_total["mean_codelets_run"],
|
|
134
|
+
expected_mean=gold_total["mean_codelets_run"],
|
|
135
|
+
observed_standard_error=observed_total["codelets_standard_error"],
|
|
136
|
+
expected_standard_error=gold_total["codelets_standard_error"],
|
|
137
|
+
)
|
|
138
|
+
comparison_rows.append(
|
|
139
|
+
_comparison_row(
|
|
140
|
+
problem=problem,
|
|
141
|
+
solution="Total",
|
|
142
|
+
observed=observed_total,
|
|
143
|
+
expected=gold_total,
|
|
144
|
+
tv_distance=tv_distance,
|
|
145
|
+
codelets_z_stat=codelets_z,
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
for gold_row in gold_solutions.itertuples(index=False):
|
|
149
|
+
observed_match = observed_solutions[
|
|
150
|
+
observed_solutions["solution"] == gold_row.solution
|
|
151
|
+
]
|
|
152
|
+
if observed_match.empty:
|
|
153
|
+
observed_row = None
|
|
154
|
+
temperature_z = None
|
|
155
|
+
else:
|
|
156
|
+
observed_row = observed_match.iloc[0]
|
|
157
|
+
temperature_z = z_statistic(
|
|
158
|
+
observed_mean=observed_row["mean_temperature"],
|
|
159
|
+
expected_mean=gold_row.mean_temperature,
|
|
160
|
+
observed_standard_error=observed_row["temperature_standard_error"],
|
|
161
|
+
expected_standard_error=(gold_row.temperature_standard_error),
|
|
162
|
+
)
|
|
163
|
+
comparison_rows.append(
|
|
164
|
+
_comparison_row(
|
|
165
|
+
problem=problem,
|
|
166
|
+
solution=gold_row.solution,
|
|
167
|
+
observed=observed_row,
|
|
168
|
+
expected=gold_row._asdict(),
|
|
169
|
+
temperature_z_stat=temperature_z,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
comparison = pd.DataFrame(
|
|
173
|
+
comparison_rows,
|
|
174
|
+
columns=[
|
|
175
|
+
"problem",
|
|
176
|
+
"solution",
|
|
177
|
+
"observed_frequency",
|
|
178
|
+
"expected_frequency",
|
|
179
|
+
"frequency_difference",
|
|
180
|
+
"observed_mean_codelets_run",
|
|
181
|
+
"expected_mean_codelets_run",
|
|
182
|
+
"mean_codelets_run_difference",
|
|
183
|
+
"codelets_relative_error_magnitude",
|
|
184
|
+
"observed_codelets_standard_error",
|
|
185
|
+
"expected_codelets_standard_error",
|
|
186
|
+
"codelets_standard_error_difference",
|
|
187
|
+
"observed_mean_temperature",
|
|
188
|
+
"expected_mean_temperature",
|
|
189
|
+
"mean_temperature_difference",
|
|
190
|
+
"observed_temperature_standard_error",
|
|
191
|
+
"expected_temperature_standard_error",
|
|
192
|
+
"temperature_standard_error_difference",
|
|
193
|
+
"tv_distance",
|
|
194
|
+
"codelets_z_stat",
|
|
195
|
+
"temperature_z_stat",
|
|
196
|
+
],
|
|
197
|
+
)
|
|
198
|
+
summary = _reproduction_summary(comparison)
|
|
199
|
+
if verbose:
|
|
200
|
+
print(
|
|
201
|
+
comparison.to_string(
|
|
202
|
+
index=False,
|
|
203
|
+
na_rep="-",
|
|
204
|
+
formatters={
|
|
205
|
+
"tv_distance": lambda x: f"{x:.4f}",
|
|
206
|
+
"codelets_z_stat": lambda x: f"{x:.2f}",
|
|
207
|
+
"codelets_relative_error_magnitude": lambda x: f"{x:.1%}",
|
|
208
|
+
"temperature_z_stat": lambda x: f"{x:.2f}",
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
for key, value in summary.items():
|
|
213
|
+
print(f"{key}: {value}")
|
|
214
|
+
if output_file:
|
|
215
|
+
comparison.to_csv(output_file, index=False)
|
|
216
|
+
return comparison, summary
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _comparison_row(
|
|
220
|
+
*,
|
|
221
|
+
problem: str,
|
|
222
|
+
solution: str,
|
|
223
|
+
observed: pd.Series | dict[str, object] | None,
|
|
224
|
+
expected: pd.Series | dict[str, object] | None,
|
|
225
|
+
tv_distance: float | None = None,
|
|
226
|
+
codelets_z_stat: float | None = None,
|
|
227
|
+
temperature_z_stat: float | None = None,
|
|
228
|
+
) -> dict[str, str | float | int | None]:
|
|
229
|
+
"""Build one observed-versus-expected comparison row."""
|
|
230
|
+
row: dict[str, str | float | int | None] = {
|
|
231
|
+
"problem": problem,
|
|
232
|
+
"solution": solution,
|
|
233
|
+
"tv_distance": tv_distance,
|
|
234
|
+
"codelets_z_stat": codelets_z_stat,
|
|
235
|
+
"temperature_z_stat": temperature_z_stat,
|
|
236
|
+
}
|
|
237
|
+
for field in (
|
|
238
|
+
"frequency",
|
|
239
|
+
"mean_codelets_run",
|
|
240
|
+
"codelets_standard_error",
|
|
241
|
+
"mean_temperature",
|
|
242
|
+
"temperature_standard_error",
|
|
243
|
+
):
|
|
244
|
+
observed_value = _row_value(observed, field)
|
|
245
|
+
expected_value = _row_value(expected, field)
|
|
246
|
+
row[f"observed_{field}"] = observed_value
|
|
247
|
+
row[f"expected_{field}"] = expected_value
|
|
248
|
+
row[f"{field}_difference"] = _difference(
|
|
249
|
+
observed_value,
|
|
250
|
+
expected_value,
|
|
251
|
+
)
|
|
252
|
+
row["codelets_relative_error_magnitude"] = _relative_error_magnitude(
|
|
253
|
+
row["observed_mean_codelets_run"],
|
|
254
|
+
row["expected_mean_codelets_run"],
|
|
255
|
+
)
|
|
256
|
+
return row
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _row_value(
|
|
260
|
+
row: pd.Series | dict[str, object] | None,
|
|
261
|
+
field: str,
|
|
262
|
+
) -> float | int | None:
|
|
263
|
+
if row is None:
|
|
264
|
+
return 0 if field == "frequency" else None
|
|
265
|
+
value = row.get(field)
|
|
266
|
+
return None if pd.isna(value) else value
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _difference(
|
|
270
|
+
observed: float | int | None,
|
|
271
|
+
expected: float | int | None,
|
|
272
|
+
) -> float | int | None:
|
|
273
|
+
if observed is None or expected is None:
|
|
274
|
+
return None
|
|
275
|
+
return observed - expected
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _relative_error_magnitude(
|
|
279
|
+
observed: float | int | None,
|
|
280
|
+
expected: float | int | None,
|
|
281
|
+
) -> float | None:
|
|
282
|
+
"""Return the magnitude of error relative to the expected value."""
|
|
283
|
+
if observed is None or expected is None or expected == 0:
|
|
284
|
+
return None
|
|
285
|
+
return abs(observed - expected) / expected
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _gold_behaviour_dataframe(
|
|
289
|
+
gold_behaviour: dict[str, pd.DataFrame | list[dict]],
|
|
290
|
+
) -> pd.DataFrame:
|
|
291
|
+
"""Combine per-problem gold summaries into one DataFrame."""
|
|
292
|
+
frames: list[pd.DataFrame] = []
|
|
293
|
+
for problem, behaviour in gold_behaviour.items():
|
|
294
|
+
frame = (
|
|
295
|
+
behaviour.copy()
|
|
296
|
+
if isinstance(behaviour, pd.DataFrame)
|
|
297
|
+
else pd.DataFrame(behaviour)
|
|
298
|
+
)
|
|
299
|
+
frame.insert(0, "problem", problem)
|
|
300
|
+
frames.append(frame)
|
|
301
|
+
if not frames:
|
|
302
|
+
return pd.DataFrame(columns=SUMMARY_COLUMNS)
|
|
303
|
+
return pd.concat(frames, ignore_index=True)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _total_row(
|
|
307
|
+
results: pd.DataFrame,
|
|
308
|
+
problem: str,
|
|
309
|
+
description: str,
|
|
310
|
+
) -> pd.Series:
|
|
311
|
+
"""Return the single totals row for a problem."""
|
|
312
|
+
total = results[results["solution"] == "Total"]
|
|
313
|
+
if len(total) != 1:
|
|
314
|
+
raise ValueError(
|
|
315
|
+
f"Expected exactly one Total row for {description} behaviour "
|
|
316
|
+
f"of {problem!r}, found {len(total)}."
|
|
317
|
+
)
|
|
318
|
+
return total.iloc[0]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _reproduction_summary(comparison: pd.DataFrame) -> dict[str, object]:
|
|
322
|
+
"""Calculate aggregate reproduction statistics from a comparison table."""
|
|
323
|
+
tv_distances = comparison["tv_distance"].dropna().tolist()
|
|
324
|
+
codelets_z_scores = comparison["codelets_z_stat"].dropna().tolist()
|
|
325
|
+
codelets_relative_error_magnitudes = comparison[
|
|
326
|
+
"codelets_relative_error_magnitude"
|
|
327
|
+
].dropna().tolist()
|
|
328
|
+
temperature_z_scores = comparison.loc[
|
|
329
|
+
comparison["expected_frequency"] >= 10,
|
|
330
|
+
"temperature_z_stat",
|
|
331
|
+
].dropna().tolist()
|
|
332
|
+
temperature_absolute_error_magnitudes = comparison.loc[
|
|
333
|
+
comparison["expected_frequency"] >= 10,
|
|
334
|
+
"mean_temperature_difference",
|
|
335
|
+
].dropna().abs().tolist()
|
|
336
|
+
return {
|
|
337
|
+
"mean_total_variation_distance": _mean(tv_distances),
|
|
338
|
+
"max_total_variation_distance": max(tv_distances, default=0.0),
|
|
339
|
+
"mean_codelets_relative_error_magnitude": _mean(
|
|
340
|
+
codelets_relative_error_magnitudes,
|
|
341
|
+
),
|
|
342
|
+
"max_codelets_relative_error_magnitude": max(
|
|
343
|
+
codelets_relative_error_magnitudes,
|
|
344
|
+
default=0.0,
|
|
345
|
+
),
|
|
346
|
+
"codelets_relative_error_magnitude_quantiles": _deciles(
|
|
347
|
+
codelets_relative_error_magnitudes,
|
|
348
|
+
),
|
|
349
|
+
"mean_temperature_absolute_error_magnitude": _mean(
|
|
350
|
+
temperature_absolute_error_magnitudes,
|
|
351
|
+
),
|
|
352
|
+
"max_temperature_absolute_error_magnitude": max(
|
|
353
|
+
temperature_absolute_error_magnitudes,
|
|
354
|
+
default=0.0,
|
|
355
|
+
),
|
|
356
|
+
"temperature_absolute_error_magnitude_quantiles": _deciles(
|
|
357
|
+
temperature_absolute_error_magnitudes,
|
|
358
|
+
),
|
|
359
|
+
**_z_score_summary("codelets", codelets_z_scores),
|
|
360
|
+
**_z_score_summary("temperature", temperature_z_scores),
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _z_score_summary(prefix: str, z_scores: list[float]) -> dict[str, float]:
|
|
365
|
+
"""Return chi-square and descriptive statistics for independent z scores."""
|
|
366
|
+
statistic = sum(z_score**2 for z_score in z_scores)
|
|
367
|
+
return {
|
|
368
|
+
f"{prefix}_chi_square_statistic": statistic,
|
|
369
|
+
f"{prefix}_chi_square_p_value": chi_square_survival_function(
|
|
370
|
+
statistic,
|
|
371
|
+
degrees_of_freedom=len(z_scores),
|
|
372
|
+
),
|
|
373
|
+
f"{prefix}_rms_z_score": sqrt(statistic / len(z_scores)) if z_scores else 0.0,
|
|
374
|
+
f"{prefix}_max_absolute_z_score": max(
|
|
375
|
+
(abs(z_score) for z_score in z_scores),
|
|
376
|
+
default=0.0,
|
|
377
|
+
),
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _mean(values: list[float]) -> float:
|
|
382
|
+
return sum(values) / len(values) if values else 0.0
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _deciles(values: list[float]) -> dict[int, float]:
|
|
386
|
+
"""Return the 10th through 100th percentiles, keyed by percentile."""
|
|
387
|
+
if not values:
|
|
388
|
+
return {percentile: 0.0 for percentile in range(10, 101, 10)}
|
|
389
|
+
ordered = sorted(values)
|
|
390
|
+
result = {}
|
|
391
|
+
for percentile in range(10, 101, 10):
|
|
392
|
+
position = (len(ordered) - 1) * percentile / 100
|
|
393
|
+
lower = int(position)
|
|
394
|
+
upper = min(lower + 1, len(ordered) - 1)
|
|
395
|
+
fraction = position - lower
|
|
396
|
+
result[percentile] = ordered[lower] + fraction * (
|
|
397
|
+
ordered[upper] - ordered[lower]
|
|
398
|
+
)
|
|
399
|
+
return result
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def chi_square_survival_function(
|
|
403
|
+
statistic: float,
|
|
404
|
+
degrees_of_freedom: int,
|
|
405
|
+
) -> float:
|
|
406
|
+
"""Return ``P(X >= statistic)`` for a chi-square random variable.
|
|
407
|
+
|
|
408
|
+
This is the regularized upper incomplete gamma function evaluated at
|
|
409
|
+
``statistic / 2`` and avoids making SciPy a runtime dependency.
|
|
410
|
+
"""
|
|
411
|
+
if degrees_of_freedom < 1:
|
|
412
|
+
return 1.0
|
|
413
|
+
if statistic <= 0:
|
|
414
|
+
return 1.0
|
|
415
|
+
if not isfinite(statistic):
|
|
416
|
+
return 0.0 if statistic == inf else float("nan")
|
|
417
|
+
return _regularized_gamma_q(degrees_of_freedom / 2, statistic / 2)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _regularized_gamma_q(shape: float, value: float) -> float:
|
|
421
|
+
"""Evaluate the regularized upper incomplete gamma function."""
|
|
422
|
+
epsilon = 1e-14
|
|
423
|
+
minimum = 1e-300
|
|
424
|
+
if value < shape + 1:
|
|
425
|
+
term = 1 / shape
|
|
426
|
+
total = term
|
|
427
|
+
for index in range(1, 10_000):
|
|
428
|
+
term *= value / (shape + index)
|
|
429
|
+
total += term
|
|
430
|
+
if abs(term) <= abs(total) * epsilon:
|
|
431
|
+
break
|
|
432
|
+
return 1 - total * exp(-value + shape * log(value) - lgamma(shape))
|
|
433
|
+
|
|
434
|
+
denominator = value + 1 - shape
|
|
435
|
+
continued_fraction = 1 / minimum
|
|
436
|
+
reciprocal = 1 / denominator
|
|
437
|
+
result = reciprocal
|
|
438
|
+
for index in range(1, 10_000):
|
|
439
|
+
numerator = -index * (index - shape)
|
|
440
|
+
denominator += 2
|
|
441
|
+
reciprocal = numerator * reciprocal + denominator
|
|
442
|
+
if abs(reciprocal) < minimum:
|
|
443
|
+
reciprocal = minimum
|
|
444
|
+
continued_fraction = denominator + numerator / continued_fraction
|
|
445
|
+
if abs(continued_fraction) < minimum:
|
|
446
|
+
continued_fraction = minimum
|
|
447
|
+
reciprocal = 1 / reciprocal
|
|
448
|
+
delta = reciprocal * continued_fraction
|
|
449
|
+
result *= delta
|
|
450
|
+
if abs(delta - 1) <= epsilon:
|
|
451
|
+
break
|
|
452
|
+
return exp(-value + shape * log(value) - lgamma(shape)) * result
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def total_variation_distance(
|
|
456
|
+
observed: dict[str, int],
|
|
457
|
+
expected: dict[str, int],
|
|
458
|
+
) -> float:
|
|
459
|
+
"""Return total variation distance between two discrete distributions."""
|
|
460
|
+
observed_total = sum(observed.values())
|
|
461
|
+
expected_total = sum(expected.values())
|
|
462
|
+
if observed_total == 0 or expected_total == 0:
|
|
463
|
+
raise ValueError("Distributions must contain at least one observation.")
|
|
464
|
+
answers = observed.keys() | expected.keys()
|
|
465
|
+
return 0.5 * sum(
|
|
466
|
+
abs(
|
|
467
|
+
observed.get(answer, 0) / observed_total
|
|
468
|
+
- expected.get(answer, 0) / expected_total
|
|
469
|
+
)
|
|
470
|
+
for answer in answers
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def z_statistic(
|
|
475
|
+
observed_mean: float,
|
|
476
|
+
expected_mean: float,
|
|
477
|
+
observed_standard_error: float,
|
|
478
|
+
expected_standard_error: float,
|
|
479
|
+
) -> float:
|
|
480
|
+
"""Return the z statistic for two means with independent errors."""
|
|
481
|
+
standard_error = sqrt(observed_standard_error**2 + expected_standard_error**2)
|
|
482
|
+
difference = observed_mean - expected_mean
|
|
483
|
+
if standard_error == 0:
|
|
484
|
+
if difference == 0:
|
|
485
|
+
return 0.0
|
|
486
|
+
return float("inf") if difference > 0 else float("-inf")
|
|
487
|
+
return difference / standard_error
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def two_sided_p_value(z_score: float) -> float:
|
|
491
|
+
"""Return the two-sided normal-distribution p value for a z statistic."""
|
|
492
|
+
return erfc(abs(z_score) / sqrt(2))
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _terminal_rule(character: str) -> str:
|
|
496
|
+
"""Return a full-width terminal separator."""
|
|
497
|
+
return character * get_terminal_size(fallback=(80, 24)).columns
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
class RunningAnswerDisplay:
|
|
501
|
+
def __init__(self):
|
|
502
|
+
self.lines_printed = 0
|
|
503
|
+
|
|
504
|
+
def update(self, results: pd.DataFrame) -> None:
|
|
505
|
+
table = (
|
|
506
|
+
results.drop(columns="problem")
|
|
507
|
+
.rename(
|
|
508
|
+
columns={
|
|
509
|
+
"solution": "Solution",
|
|
510
|
+
"frequency": "Runs",
|
|
511
|
+
"mean_codelets_run": "Mean codelets",
|
|
512
|
+
"codelets_standard_error": "Codelets SE",
|
|
513
|
+
"mean_temperature": "Mean temp",
|
|
514
|
+
"temperature_standard_error": "Temp SE",
|
|
515
|
+
}
|
|
516
|
+
)
|
|
517
|
+
.to_string(
|
|
518
|
+
index=False,
|
|
519
|
+
formatters={
|
|
520
|
+
"Mean codelets": "{:.0f}".format,
|
|
521
|
+
"Codelets SE": "{:.1f}".format,
|
|
522
|
+
"Mean temp": "{:.2f}".format,
|
|
523
|
+
"Temp SE": "{:.4f}".format,
|
|
524
|
+
},
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
if self.lines_printed:
|
|
528
|
+
print(
|
|
529
|
+
f"\033[{self.lines_printed}A",
|
|
530
|
+
end="",
|
|
531
|
+
)
|
|
532
|
+
for _ in range(self.lines_printed):
|
|
533
|
+
print("\033[2K")
|
|
534
|
+
print(
|
|
535
|
+
f"\033[{self.lines_printed}A",
|
|
536
|
+
end="",
|
|
537
|
+
)
|
|
538
|
+
lines = table.splitlines()
|
|
539
|
+
for line in lines:
|
|
540
|
+
print(f"\033[2K{line}")
|
|
541
|
+
terminal_width = get_terminal_size(fallback=(80, 24)).columns
|
|
542
|
+
self.lines_printed = sum(
|
|
543
|
+
max(
|
|
544
|
+
1,
|
|
545
|
+
(len(line) + terminal_width - 1) // terminal_width,
|
|
546
|
+
)
|
|
547
|
+
for line in lines
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def summarize_runs(
|
|
552
|
+
runs: list[dict[str, str | float | int | None]],
|
|
553
|
+
problem: str,
|
|
554
|
+
) -> pd.DataFrame:
|
|
555
|
+
"""Summarize individual runs, including an overall totals row."""
|
|
556
|
+
data = pd.DataFrame(runs)
|
|
557
|
+
if data.empty:
|
|
558
|
+
return pd.DataFrame(columns=SUMMARY_COLUMNS)
|
|
559
|
+
grouped = data.groupby(
|
|
560
|
+
"solution",
|
|
561
|
+
sort=False,
|
|
562
|
+
)
|
|
563
|
+
summary = grouped.agg(
|
|
564
|
+
frequency=("solution", "size"),
|
|
565
|
+
mean_codelets_run=("codelets_run", "mean"),
|
|
566
|
+
codelets_standard_error=("codelets_run", "sem"),
|
|
567
|
+
mean_temperature=("temperature", "mean"),
|
|
568
|
+
temperature_standard_error=("temperature", "sem"),
|
|
569
|
+
).reset_index()
|
|
570
|
+
summary = summary.sort_values(
|
|
571
|
+
"frequency",
|
|
572
|
+
ascending=False,
|
|
573
|
+
kind="stable",
|
|
574
|
+
)
|
|
575
|
+
error_columns = [
|
|
576
|
+
"codelets_standard_error",
|
|
577
|
+
"temperature_standard_error",
|
|
578
|
+
]
|
|
579
|
+
summary[error_columns] = summary[error_columns].fillna(0.0)
|
|
580
|
+
totals = pd.DataFrame(
|
|
581
|
+
[
|
|
582
|
+
{
|
|
583
|
+
"solution": "Total",
|
|
584
|
+
"frequency": len(data),
|
|
585
|
+
"mean_codelets_run": (data["codelets_run"].mean()),
|
|
586
|
+
"codelets_standard_error": (data["codelets_run"].sem()),
|
|
587
|
+
"mean_temperature": (data["temperature"].mean()),
|
|
588
|
+
"temperature_standard_error": (data["temperature"].sem()),
|
|
589
|
+
}
|
|
590
|
+
]
|
|
591
|
+
)
|
|
592
|
+
totals[error_columns] = totals[error_columns].fillna(0.0)
|
|
593
|
+
summary = pd.concat(
|
|
594
|
+
[summary, totals],
|
|
595
|
+
ignore_index=True,
|
|
596
|
+
)
|
|
597
|
+
summary.insert(
|
|
598
|
+
0,
|
|
599
|
+
"problem",
|
|
600
|
+
problem,
|
|
601
|
+
)
|
|
602
|
+
return summary[SUMMARY_COLUMNS]
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
"""Structured logging for CattyKit model runs."""
|
|
2
2
|
|
|
3
|
-
from .
|
|
3
|
+
from .cattycam_logger import CattycamLogger
|
|
4
4
|
from .model_logger import ModelLogger
|
|
5
5
|
from .null_logger import NullLogger
|
|
6
6
|
from .print_logger import PrintLogger
|
|
7
|
-
from .sqlite_logger import SQLiteLogger
|
|
8
7
|
|
|
9
|
-
__all__ = ["
|
|
8
|
+
__all__ = ["CattycamLogger", "ModelLogger", "NullLogger", "PrintLogger"]
|