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.
Files changed (29) hide show
  1. {cattykit-0.1.2 → cattykit-0.1.3}/PKG-INFO +1 -1
  2. {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/__init__.py +2 -1
  3. cattykit-0.1.3/cattykit/experiments/__init__.py +25 -0
  4. cattykit-0.1.3/cattykit/experiments/reproduction.py +226 -0
  5. cattykit-0.1.3/cattykit/experiments/runner.py +122 -0
  6. cattykit-0.1.3/cattykit/experiments/statistics.py +171 -0
  7. {cattykit-0.1.2 → cattykit-0.1.3}/cattykit.egg-info/PKG-INFO +1 -1
  8. {cattykit-0.1.2 → cattykit-0.1.3}/cattykit.egg-info/SOURCES.txt +4 -1
  9. {cattykit-0.1.2 → cattykit-0.1.3}/pyproject.toml +1 -1
  10. cattykit-0.1.2/cattykit/experiments.py +0 -602
  11. {cattykit-0.1.2 → cattykit-0.1.3}/LICENSE +0 -0
  12. {cattykit-0.1.2 → cattykit-0.1.3}/README.md +0 -0
  13. {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/cli.py +0 -0
  14. {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/logging/__init__.py +0 -0
  15. {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/logging/cattycam_logger.py +0 -0
  16. {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/logging/identifiers.py +0 -0
  17. {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/logging/model_logger.py +0 -0
  18. {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/logging/null_logger.py +0 -0
  19. {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/logging/print_logger.py +0 -0
  20. {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/models/__init__.py +0 -0
  21. {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/models/loading.py +0 -0
  22. {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/models/protocol.py +0 -0
  23. {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/models/registry.py +0 -0
  24. {cattykit-0.1.2 → cattykit-0.1.3}/cattykit/py.typed +0 -0
  25. {cattykit-0.1.2 → cattykit-0.1.3}/cattykit.egg-info/dependency_links.txt +0 -0
  26. {cattykit-0.1.2 → cattykit-0.1.3}/cattykit.egg-info/entry_points.txt +0 -0
  27. {cattykit-0.1.2 → cattykit-0.1.3}/cattykit.egg-info/requires.txt +0 -0
  28. {cattykit-0.1.2 → cattykit-0.1.3}/cattykit.egg-info/top_level.txt +0 -0
  29. {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.2
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.2
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.2"
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