cattykit 0.1.2__tar.gz → 0.1.3__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.2 → cattykit-0.1.3}/PKG-INFO +1 -1
- {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/__init__.py +2 -1
- cattykit-0.1.3/cattykit/experiments/__init__.py +25 -0
- cattykit-0.1.3/cattykit/experiments/reproduction.py +226 -0
- cattykit-0.1.3/cattykit/experiments/runner.py +122 -0
- cattykit-0.1.3/cattykit/experiments/statistics.py +171 -0
- {cattykit-0.1.2 → cattykit-0.1.3}/cattykit.egg-info/PKG-INFO +1 -1
- {cattykit-0.1.2 → cattykit-0.1.3}/cattykit.egg-info/SOURCES.txt +4 -1
- {cattykit-0.1.2 → cattykit-0.1.3}/pyproject.toml +1 -1
- cattykit-0.1.2/cattykit/experiments.py +0 -602
- {cattykit-0.1.2 → cattykit-0.1.3}/LICENSE +0 -0
- {cattykit-0.1.2 → cattykit-0.1.3}/README.md +0 -0
- {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/cli.py +0 -0
- {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/logging/__init__.py +0 -0
- {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/logging/cattycam_logger.py +0 -0
- {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/logging/identifiers.py +0 -0
- {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/logging/model_logger.py +0 -0
- {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/logging/null_logger.py +0 -0
- {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/logging/print_logger.py +0 -0
- {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/models/__init__.py +0 -0
- {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/models/loading.py +0 -0
- {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/models/protocol.py +0 -0
- {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/models/registry.py +0 -0
- {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/py.typed +0 -0
- {cattykit-0.1.2 → cattykit-0.1.3}/cattykit.egg-info/dependency_links.txt +0 -0
- {cattykit-0.1.2 → cattykit-0.1.3}/cattykit.egg-info/entry_points.txt +0 -0
- {cattykit-0.1.2 → cattykit-0.1.3}/cattykit.egg-info/requires.txt +0 -0
- {cattykit-0.1.2 → cattykit-0.1.3}/cattykit.egg-info/top_level.txt +0 -0
- {cattykit-0.1.2 → cattykit-0.1.3}/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.3
|
|
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
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
from .experiments import run_experiment
|
|
1
|
+
from .experiments import ExperimentResult, run_experiment
|
|
2
2
|
from .models import available_models, install_model, load_model, model_info
|
|
3
3
|
|
|
4
4
|
__all__ = [
|
|
5
5
|
"available_models",
|
|
6
|
+
"ExperimentResult",
|
|
6
7
|
"install_model",
|
|
7
8
|
"load_model",
|
|
8
9
|
"model_info",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Public experiment-running and statistical-analysis API."""
|
|
2
|
+
|
|
3
|
+
from .reproduction import reproduction_summary, run_reproduction
|
|
4
|
+
from .runner import ExperimentResult, run_experiment
|
|
5
|
+
from .statistics import (
|
|
6
|
+
chi_square_survival_function,
|
|
7
|
+
mean_absolute_error,
|
|
8
|
+
summarize_runs,
|
|
9
|
+
total_variation_distance,
|
|
10
|
+
two_sided_p_value,
|
|
11
|
+
z_statistic,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ExperimentResult",
|
|
16
|
+
"chi_square_survival_function",
|
|
17
|
+
"mean_absolute_error",
|
|
18
|
+
"run_experiment",
|
|
19
|
+
"run_reproduction",
|
|
20
|
+
"reproduction_summary",
|
|
21
|
+
"summarize_runs",
|
|
22
|
+
"total_variation_distance",
|
|
23
|
+
"two_sided_p_value",
|
|
24
|
+
"z_statistic",
|
|
25
|
+
]
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Comparison of repeated model runs with reference behaviour."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from .runner import run_experiment
|
|
11
|
+
from .statistics import total_variation_distance, z_statistic
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_reproduction(
|
|
15
|
+
model_name: str,
|
|
16
|
+
gold_behaviour: dict[str, pd.DataFrame | list[dict[str, Any]]],
|
|
17
|
+
iterations: int,
|
|
18
|
+
output_file: str | Path | None = None,
|
|
19
|
+
) -> tuple[pd.DataFrame, dict[str, Any]]:
|
|
20
|
+
"""Run a model and compare its standard summaries with reference behaviour.
|
|
21
|
+
|
|
22
|
+
``gold_behaviour`` maps each problem to rows using the schema returned by
|
|
23
|
+
:func:`summarize_runs`, excluding its ``problem`` column. The comparison
|
|
24
|
+
contains one row for each reference answer and each problem's ``Total``
|
|
25
|
+
row. This makes the routine suitable for any Cattykit model that records
|
|
26
|
+
the standard answer, temperature, and codelet-count measurements.
|
|
27
|
+
"""
|
|
28
|
+
observed = run_experiment(
|
|
29
|
+
model_name, list(gold_behaviour), iterations, verbose=False
|
|
30
|
+
).summary
|
|
31
|
+
rows: list[dict[str, object]] = []
|
|
32
|
+
for problem, behaviour in gold_behaviour.items():
|
|
33
|
+
expected = _as_dataframe(behaviour)
|
|
34
|
+
actual = observed.loc[observed["problem"] == problem]
|
|
35
|
+
actual_answers = actual.loc[actual["solution"] != "Total"]
|
|
36
|
+
expected_answers = expected.loc[expected["solution"] != "Total"]
|
|
37
|
+
rows.append(
|
|
38
|
+
_comparison_row(
|
|
39
|
+
problem=problem,
|
|
40
|
+
solution="Total",
|
|
41
|
+
observed=_total_row(actual, problem, "observed"),
|
|
42
|
+
expected=_total_row(expected, problem, "reference"),
|
|
43
|
+
tv_distance=total_variation_distance(
|
|
44
|
+
dict(
|
|
45
|
+
zip(
|
|
46
|
+
actual_answers["solution"],
|
|
47
|
+
actual_answers["frequency"],
|
|
48
|
+
strict=True,
|
|
49
|
+
)
|
|
50
|
+
),
|
|
51
|
+
dict(
|
|
52
|
+
zip(
|
|
53
|
+
expected_answers["solution"],
|
|
54
|
+
expected_answers["frequency"],
|
|
55
|
+
strict=True,
|
|
56
|
+
)
|
|
57
|
+
),
|
|
58
|
+
),
|
|
59
|
+
codelets_z_stat=_z_statistic(
|
|
60
|
+
_total_row(actual, problem, "observed"),
|
|
61
|
+
_total_row(expected, problem, "reference"),
|
|
62
|
+
"mean_codelets_run",
|
|
63
|
+
"codelets_standard_error",
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
for expected_answer in expected_answers.itertuples(index=False):
|
|
68
|
+
matches = actual_answers.loc[
|
|
69
|
+
actual_answers["solution"] == expected_answer.solution
|
|
70
|
+
]
|
|
71
|
+
actual_answer = None if matches.empty else matches.iloc[0]
|
|
72
|
+
rows.append(
|
|
73
|
+
_comparison_row(
|
|
74
|
+
problem=problem,
|
|
75
|
+
solution=str(expected_answer.solution),
|
|
76
|
+
observed=actual_answer,
|
|
77
|
+
expected=expected_answer._asdict(),
|
|
78
|
+
temperature_z_stat=(
|
|
79
|
+
None
|
|
80
|
+
if actual_answer is None
|
|
81
|
+
else _z_statistic(
|
|
82
|
+
actual_answer,
|
|
83
|
+
expected_answer._asdict(),
|
|
84
|
+
"mean_temperature",
|
|
85
|
+
"temperature_standard_error",
|
|
86
|
+
)
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
comparison = pd.DataFrame(rows)
|
|
91
|
+
if output_file is not None:
|
|
92
|
+
comparison.to_csv(output_file, index=False)
|
|
93
|
+
return comparison, reproduction_summary(comparison)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def reproduction_summary(comparison: pd.DataFrame) -> dict[str, Any]:
|
|
97
|
+
"""Summarize the generic error measures in a reproduction comparison.
|
|
98
|
+
|
|
99
|
+
Temperature errors are weighted by the reference answer frequency. The
|
|
100
|
+
maximum temperature metric is the maximum of the frequency-weighted mean
|
|
101
|
+
errors for each problem, rather than the error of a single answer.
|
|
102
|
+
"""
|
|
103
|
+
totals = comparison.loc[comparison["solution"] == "Total"]
|
|
104
|
+
answer_rows = comparison.loc[comparison["solution"] != "Total"]
|
|
105
|
+
temperature_rows = answer_rows.loc[
|
|
106
|
+
answer_rows["mean_temperature_difference"].notna()
|
|
107
|
+
& answer_rows["expected_frequency"].notna()
|
|
108
|
+
].copy()
|
|
109
|
+
temperature_rows["absolute_temperature_error"] = temperature_rows[
|
|
110
|
+
"mean_temperature_difference"
|
|
111
|
+
].abs()
|
|
112
|
+
temperature_mean_error = _frequency_weighted_mean(temperature_rows)
|
|
113
|
+
problem_temperature_mean_errors = {
|
|
114
|
+
problem: _frequency_weighted_mean(rows)
|
|
115
|
+
for problem, rows in temperature_rows.groupby("problem", sort=False)
|
|
116
|
+
}
|
|
117
|
+
codelets_errors = totals["codelets_relative_error_magnitude"].dropna()
|
|
118
|
+
return {
|
|
119
|
+
"mean_total_variation_distance": totals["tv_distance"].mean(),
|
|
120
|
+
"max_total_variation_distance": totals["tv_distance"].max(),
|
|
121
|
+
"mean_temperature_absolute_error_magnitude": temperature_mean_error,
|
|
122
|
+
"max_temperature_absolute_error_magnitude": max(
|
|
123
|
+
problem_temperature_mean_errors.values(), default=0.0
|
|
124
|
+
),
|
|
125
|
+
"temperature_absolute_error_magnitude_by_problem": (
|
|
126
|
+
problem_temperature_mean_errors
|
|
127
|
+
),
|
|
128
|
+
"mean_codelets_relative_error_magnitude": codelets_errors.mean(),
|
|
129
|
+
"max_codelets_relative_error_magnitude": codelets_errors.max(),
|
|
130
|
+
"codelets_relative_error_magnitude_quantiles": {
|
|
131
|
+
percentile: codelets_errors.quantile(percentile / 100)
|
|
132
|
+
for percentile in range(10, 101, 10)
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _frequency_weighted_mean(rows: pd.DataFrame) -> float:
|
|
138
|
+
"""Return the reference-frequency-weighted temperature error for rows."""
|
|
139
|
+
total_frequency = rows["expected_frequency"].sum()
|
|
140
|
+
if total_frequency == 0:
|
|
141
|
+
return 0.0
|
|
142
|
+
return float(
|
|
143
|
+
(rows["absolute_temperature_error"] * rows["expected_frequency"]).sum()
|
|
144
|
+
/ total_frequency
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _as_dataframe(behaviour: pd.DataFrame | list[dict[str, Any]]) -> pd.DataFrame:
|
|
149
|
+
if isinstance(behaviour, pd.DataFrame):
|
|
150
|
+
return behaviour.copy()
|
|
151
|
+
return pd.DataFrame(behaviour)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _total_row(results: pd.DataFrame, problem: str, description: str) -> pd.Series:
|
|
155
|
+
totals = results.loc[results["solution"] == "Total"]
|
|
156
|
+
if len(totals) != 1:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"Expected exactly one Total row for {description} behaviour of "
|
|
159
|
+
f"{problem!r}, found {len(totals)}."
|
|
160
|
+
)
|
|
161
|
+
return totals.iloc[0]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _z_statistic(
|
|
165
|
+
observed: pd.Series | dict[str, Any],
|
|
166
|
+
expected: pd.Series | dict[str, Any],
|
|
167
|
+
mean_column: str,
|
|
168
|
+
standard_error_column: str,
|
|
169
|
+
) -> float | None:
|
|
170
|
+
values = (
|
|
171
|
+
observed[mean_column],
|
|
172
|
+
expected[mean_column],
|
|
173
|
+
observed[standard_error_column],
|
|
174
|
+
expected[standard_error_column],
|
|
175
|
+
)
|
|
176
|
+
return None if any(pd.isna(value) for value in values) else z_statistic(*values)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _comparison_row(
|
|
180
|
+
*,
|
|
181
|
+
problem: str,
|
|
182
|
+
solution: str,
|
|
183
|
+
observed: pd.Series | None,
|
|
184
|
+
expected: pd.Series | dict[str, Any],
|
|
185
|
+
tv_distance: float | None = None,
|
|
186
|
+
codelets_z_stat: float | None = None,
|
|
187
|
+
temperature_z_stat: float | None = None,
|
|
188
|
+
) -> dict[str, object]:
|
|
189
|
+
row: dict[str, object] = {
|
|
190
|
+
"problem": problem,
|
|
191
|
+
"solution": solution,
|
|
192
|
+
"tv_distance": tv_distance,
|
|
193
|
+
"codelets_z_stat": codelets_z_stat,
|
|
194
|
+
"temperature_z_stat": temperature_z_stat,
|
|
195
|
+
}
|
|
196
|
+
for field in (
|
|
197
|
+
"frequency",
|
|
198
|
+
"mean_codelets_run",
|
|
199
|
+
"codelets_standard_error",
|
|
200
|
+
"mean_temperature",
|
|
201
|
+
"temperature_standard_error",
|
|
202
|
+
):
|
|
203
|
+
actual = (
|
|
204
|
+
0 if observed is None and field == "frequency" else _value(observed, field)
|
|
205
|
+
)
|
|
206
|
+
reference = _value(expected, field)
|
|
207
|
+
row[f"observed_{field}"] = actual
|
|
208
|
+
row[f"expected_{field}"] = reference
|
|
209
|
+
row[f"{field}_difference"] = (
|
|
210
|
+
actual - reference if actual is not None and reference is not None else None
|
|
211
|
+
)
|
|
212
|
+
observed_codelets = row["observed_mean_codelets_run"]
|
|
213
|
+
expected_codelets = row["expected_mean_codelets_run"]
|
|
214
|
+
row["codelets_relative_error_magnitude"] = (
|
|
215
|
+
abs(observed_codelets - expected_codelets) / abs(expected_codelets)
|
|
216
|
+
if observed_codelets is not None and expected_codelets not in (None, 0)
|
|
217
|
+
else None
|
|
218
|
+
)
|
|
219
|
+
return row
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _value(row: pd.Series | dict[str, Any] | None, field: str) -> object | None:
|
|
223
|
+
if row is None:
|
|
224
|
+
return None
|
|
225
|
+
value = row[field]
|
|
226
|
+
return None if pd.isna(value) else value
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Execution of repeatable model experiments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from shutil import get_terminal_size
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
from ..logging import CattycamLogger, NullLogger
|
|
15
|
+
from ..models import load_model
|
|
16
|
+
from .statistics import SUMMARY_COLUMNS, summarize_runs
|
|
17
|
+
|
|
18
|
+
RAW_RESULT_COLUMNS = ["problem", "random_seed", "answer", "codelets_run", "temperature"]
|
|
19
|
+
SummaryFunction = Callable[[list[dict[str, Any]], str], pd.DataFrame]
|
|
20
|
+
ProgressCallback = Callable[[int, int, str], None]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RunningAnswerDisplay:
|
|
24
|
+
"""Render an updating terminal view of a running experiment summary."""
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self.lines_printed = 0
|
|
28
|
+
|
|
29
|
+
def update(self, results: pd.DataFrame) -> None:
|
|
30
|
+
table = results.drop(columns="problem").to_string(index=False)
|
|
31
|
+
if self.lines_printed:
|
|
32
|
+
print(f"\033[{self.lines_printed}A", end="")
|
|
33
|
+
for _ in range(self.lines_printed):
|
|
34
|
+
print("\033[2K")
|
|
35
|
+
print(f"\033[{self.lines_printed}A", end="")
|
|
36
|
+
lines = table.splitlines()
|
|
37
|
+
for line in lines:
|
|
38
|
+
print(f"\033[2K{line}")
|
|
39
|
+
width = get_terminal_size(fallback=(80, 24)).columns
|
|
40
|
+
self.lines_printed = sum(max(1, (len(line) + width - 1) // width) for line in lines)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ExperimentResult:
|
|
45
|
+
"""Aggregate statistics and the individual runs that produced them."""
|
|
46
|
+
|
|
47
|
+
summary: pd.DataFrame
|
|
48
|
+
raw_runs: pd.DataFrame
|
|
49
|
+
|
|
50
|
+
def save_summary_csv(self, path: str | Path) -> None:
|
|
51
|
+
self.summary.to_csv(path, index=False)
|
|
52
|
+
|
|
53
|
+
def save_raw_runs_csv(self, path: str | Path) -> None:
|
|
54
|
+
self.raw_runs.to_csv(path, index=False)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run_experiment(
|
|
58
|
+
model_name: str,
|
|
59
|
+
problems: list[str],
|
|
60
|
+
iterations: int,
|
|
61
|
+
logging_db: str | Path | None = None,
|
|
62
|
+
verbose: bool = True,
|
|
63
|
+
record_attributes: list[str] | None = None,
|
|
64
|
+
summary_function: SummaryFunction = summarize_runs,
|
|
65
|
+
progress_callback: ProgressCallback | None = None,
|
|
66
|
+
) -> ExperimentResult:
|
|
67
|
+
"""Run every problem once per seed with configurable recordings and summaries."""
|
|
68
|
+
record_attributes = record_attributes or []
|
|
69
|
+
reserved = set(RAW_RESULT_COLUMNS)
|
|
70
|
+
duplicates = reserved.intersection(record_attributes)
|
|
71
|
+
if duplicates:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"record_attributes duplicate standard fields: {sorted(duplicates)}"
|
|
74
|
+
)
|
|
75
|
+
interactive = verbose and sys.stdout.isatty()
|
|
76
|
+
summaries, raw_runs = [], []
|
|
77
|
+
logger = (
|
|
78
|
+
CattycamLogger(logging_db, model_name) if logging_db else NullLogger(model_name)
|
|
79
|
+
)
|
|
80
|
+
try:
|
|
81
|
+
for problem_number, problem in enumerate(problems, start=1):
|
|
82
|
+
display = RunningAnswerDisplay() if interactive else None
|
|
83
|
+
runs = []
|
|
84
|
+
for seed in range(iterations):
|
|
85
|
+
model = load_model(model_name, config={"seed": seed}, logger=logger)
|
|
86
|
+
try:
|
|
87
|
+
answer = model.solve(problem)
|
|
88
|
+
run: dict[str, Any] = {
|
|
89
|
+
"solution": answer,
|
|
90
|
+
"codelets_run": model.coderack.number_of_codelets_run,
|
|
91
|
+
"temperature": model.temperature,
|
|
92
|
+
}
|
|
93
|
+
run.update(
|
|
94
|
+
{
|
|
95
|
+
attribute: getattr(model, attribute)
|
|
96
|
+
for attribute in record_attributes
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
runs.append(run)
|
|
100
|
+
raw_runs.append(
|
|
101
|
+
{
|
|
102
|
+
"problem": problem,
|
|
103
|
+
"random_seed": seed,
|
|
104
|
+
"answer": answer,
|
|
105
|
+
**{key: run[key] for key in run if key != "solution"},
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
if display:
|
|
109
|
+
display.update(summary_function(runs, problem))
|
|
110
|
+
finally:
|
|
111
|
+
model.close()
|
|
112
|
+
summaries.append(summary_function(runs, problem))
|
|
113
|
+
if progress_callback is not None:
|
|
114
|
+
progress_callback(problem_number, len(problems), problem)
|
|
115
|
+
finally:
|
|
116
|
+
logger.close()
|
|
117
|
+
return ExperimentResult(
|
|
118
|
+
pd.concat(summaries, ignore_index=True)
|
|
119
|
+
if summaries
|
|
120
|
+
else pd.DataFrame(columns=SUMMARY_COLUMNS),
|
|
121
|
+
pd.DataFrame(raw_runs, columns=[*RAW_RESULT_COLUMNS, *record_attributes]),
|
|
122
|
+
)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
2
|
+
from math import erfc, exp, inf, isfinite, lgamma, log, sqrt
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
SUMMARY_COLUMNS = [
|
|
7
|
+
"problem",
|
|
8
|
+
"solution",
|
|
9
|
+
"frequency",
|
|
10
|
+
"mean_codelets_run",
|
|
11
|
+
"codelets_standard_error",
|
|
12
|
+
"mean_temperature",
|
|
13
|
+
"temperature_standard_error",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def mean_absolute_error(
|
|
18
|
+
observed: Iterable[float],
|
|
19
|
+
expected: Iterable[float],
|
|
20
|
+
) -> float:
|
|
21
|
+
"""Return the mean absolute difference between equal-length measurements."""
|
|
22
|
+
pairs = list(zip(observed, expected, strict=True))
|
|
23
|
+
return (
|
|
24
|
+
sum(abs(actual - reference) for actual, reference in pairs) / len(pairs)
|
|
25
|
+
if pairs
|
|
26
|
+
else 0.0
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def chi_square_survival_function(statistic: float, degrees_of_freedom: int) -> float:
|
|
31
|
+
"""Return the chi-square survival probability without a SciPy dependency.
|
|
32
|
+
|
|
33
|
+
This evaluates the regularized upper incomplete gamma function at
|
|
34
|
+
``statistic / 2``.
|
|
35
|
+
"""
|
|
36
|
+
if degrees_of_freedom < 1:
|
|
37
|
+
return 1.0
|
|
38
|
+
if statistic <= 0:
|
|
39
|
+
return 1.0
|
|
40
|
+
if not isfinite(statistic):
|
|
41
|
+
return 0.0 if statistic == inf else float("nan")
|
|
42
|
+
return _regularized_gamma_q(degrees_of_freedom / 2, statistic / 2)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _regularized_gamma_q(shape: float, value: float) -> float:
|
|
46
|
+
"""Evaluate the regularized upper incomplete gamma function."""
|
|
47
|
+
epsilon = 1e-14
|
|
48
|
+
minimum = 1e-300
|
|
49
|
+
if value < shape + 1:
|
|
50
|
+
term = 1 / shape
|
|
51
|
+
total = term
|
|
52
|
+
for index in range(1, 10_000):
|
|
53
|
+
term *= value / (shape + index)
|
|
54
|
+
total += term
|
|
55
|
+
if abs(term) <= abs(total) * epsilon:
|
|
56
|
+
break
|
|
57
|
+
return 1 - total * exp(-value + shape * log(value) - lgamma(shape))
|
|
58
|
+
|
|
59
|
+
denominator = value + 1 - shape
|
|
60
|
+
continued_fraction = 1 / minimum
|
|
61
|
+
reciprocal = 1 / denominator
|
|
62
|
+
result = reciprocal
|
|
63
|
+
for index in range(1, 10_000):
|
|
64
|
+
numerator = -index * (index - shape)
|
|
65
|
+
denominator += 2
|
|
66
|
+
reciprocal = numerator * reciprocal + denominator
|
|
67
|
+
if abs(reciprocal) < minimum:
|
|
68
|
+
reciprocal = minimum
|
|
69
|
+
continued_fraction = denominator + numerator / continued_fraction
|
|
70
|
+
if abs(continued_fraction) < minimum:
|
|
71
|
+
continued_fraction = minimum
|
|
72
|
+
reciprocal = 1 / reciprocal
|
|
73
|
+
delta = reciprocal * continued_fraction
|
|
74
|
+
result *= delta
|
|
75
|
+
if abs(delta - 1) <= epsilon:
|
|
76
|
+
break
|
|
77
|
+
return exp(-value + shape * log(value) - lgamma(shape)) * result
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def total_variation_distance(
|
|
81
|
+
observed: dict[str, int],
|
|
82
|
+
expected: dict[str, int],
|
|
83
|
+
) -> float:
|
|
84
|
+
"""Return total variation distance between two discrete distributions."""
|
|
85
|
+
observed_total = sum(observed.values())
|
|
86
|
+
expected_total = sum(expected.values())
|
|
87
|
+
if observed_total == 0 or expected_total == 0:
|
|
88
|
+
raise ValueError("Distributions must contain at least one observation.")
|
|
89
|
+
answers = observed.keys() | expected.keys()
|
|
90
|
+
return 0.5 * sum(
|
|
91
|
+
abs(
|
|
92
|
+
observed.get(answer, 0) / observed_total
|
|
93
|
+
- expected.get(answer, 0) / expected_total
|
|
94
|
+
)
|
|
95
|
+
for answer in answers
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def z_statistic(
|
|
100
|
+
observed_mean: float,
|
|
101
|
+
expected_mean: float,
|
|
102
|
+
observed_standard_error: float,
|
|
103
|
+
expected_standard_error: float,
|
|
104
|
+
) -> float:
|
|
105
|
+
"""Return the z statistic for two means with independent errors."""
|
|
106
|
+
standard_error = sqrt(observed_standard_error**2 + expected_standard_error**2)
|
|
107
|
+
difference = observed_mean - expected_mean
|
|
108
|
+
if standard_error == 0:
|
|
109
|
+
if difference == 0:
|
|
110
|
+
return 0.0
|
|
111
|
+
return float("inf") if difference > 0 else float("-inf")
|
|
112
|
+
return difference / standard_error
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def two_sided_p_value(z_score: float) -> float:
|
|
116
|
+
"""Return the two-sided normal-distribution p value for a z statistic."""
|
|
117
|
+
return erfc(abs(z_score) / sqrt(2))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def summarize_runs(
|
|
121
|
+
runs: list[dict[str, str | float | int | None]],
|
|
122
|
+
problem: str,
|
|
123
|
+
) -> pd.DataFrame:
|
|
124
|
+
"""Summarize individual runs, including an overall totals row."""
|
|
125
|
+
data = pd.DataFrame(runs)
|
|
126
|
+
if data.empty:
|
|
127
|
+
return pd.DataFrame(columns=SUMMARY_COLUMNS)
|
|
128
|
+
grouped = data.groupby(
|
|
129
|
+
"solution",
|
|
130
|
+
sort=False,
|
|
131
|
+
)
|
|
132
|
+
summary = grouped.agg(
|
|
133
|
+
frequency=("solution", "size"),
|
|
134
|
+
mean_codelets_run=("codelets_run", "mean"),
|
|
135
|
+
codelets_standard_error=("codelets_run", "sem"),
|
|
136
|
+
mean_temperature=("temperature", "mean"),
|
|
137
|
+
temperature_standard_error=("temperature", "sem"),
|
|
138
|
+
).reset_index()
|
|
139
|
+
summary = summary.sort_values(
|
|
140
|
+
"frequency",
|
|
141
|
+
ascending=False,
|
|
142
|
+
kind="stable",
|
|
143
|
+
)
|
|
144
|
+
error_columns = [
|
|
145
|
+
"codelets_standard_error",
|
|
146
|
+
"temperature_standard_error",
|
|
147
|
+
]
|
|
148
|
+
summary[error_columns] = summary[error_columns].fillna(0.0)
|
|
149
|
+
totals = pd.DataFrame(
|
|
150
|
+
[
|
|
151
|
+
{
|
|
152
|
+
"solution": "Total",
|
|
153
|
+
"frequency": len(data),
|
|
154
|
+
"mean_codelets_run": (data["codelets_run"].mean()),
|
|
155
|
+
"codelets_standard_error": (data["codelets_run"].sem()),
|
|
156
|
+
"mean_temperature": (data["temperature"].mean()),
|
|
157
|
+
"temperature_standard_error": (data["temperature"].sem()),
|
|
158
|
+
}
|
|
159
|
+
]
|
|
160
|
+
)
|
|
161
|
+
totals[error_columns] = totals[error_columns].fillna(0.0)
|
|
162
|
+
summary = pd.concat(
|
|
163
|
+
[summary, totals],
|
|
164
|
+
ignore_index=True,
|
|
165
|
+
)
|
|
166
|
+
summary.insert(
|
|
167
|
+
0,
|
|
168
|
+
"problem",
|
|
169
|
+
problem,
|
|
170
|
+
)
|
|
171
|
+
return summary[SUMMARY_COLUMNS]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cattykit
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
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
|
|
@@ -3,7 +3,6 @@ README.md
|
|
|
3
3
|
pyproject.toml
|
|
4
4
|
cattykit/__init__.py
|
|
5
5
|
cattykit/cli.py
|
|
6
|
-
cattykit/experiments.py
|
|
7
6
|
cattykit/py.typed
|
|
8
7
|
cattykit.egg-info/PKG-INFO
|
|
9
8
|
cattykit.egg-info/SOURCES.txt
|
|
@@ -11,6 +10,10 @@ cattykit.egg-info/dependency_links.txt
|
|
|
11
10
|
cattykit.egg-info/entry_points.txt
|
|
12
11
|
cattykit.egg-info/requires.txt
|
|
13
12
|
cattykit.egg-info/top_level.txt
|
|
13
|
+
cattykit/experiments/__init__.py
|
|
14
|
+
cattykit/experiments/reproduction.py
|
|
15
|
+
cattykit/experiments/runner.py
|
|
16
|
+
cattykit/experiments/statistics.py
|
|
14
17
|
cattykit/logging/__init__.py
|
|
15
18
|
cattykit/logging/cattycam_logger.py
|
|
16
19
|
cattykit/logging/identifiers.py
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "cattykit"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.3"
|
|
4
4
|
description = "A framework for implementing artificial intelligence models using components from the cognitive models of the Fluid Analogies Research Group"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{name="George Alexander Wright", email="me@georgeaw.com"}]
|
|
@@ -1,602 +0,0 @@
|
|
|
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]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|