agent-attrib 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_attrib/__init__.py +124 -0
- agent_attrib/cli.py +250 -0
- agent_attrib/design.py +214 -0
- agent_attrib/model.py +262 -0
- agent_attrib/power.py +65 -0
- agent_attrib/py.typed +0 -0
- agent_attrib/report.py +181 -0
- agent_attrib/schema.py +183 -0
- agent_attrib/simulate.py +138 -0
- agent_attrib/verdict.py +54 -0
- agent_attrib-0.1.0.dist-info/METADATA +181 -0
- agent_attrib-0.1.0.dist-info/RECORD +14 -0
- agent_attrib-0.1.0.dist-info/WHEEL +4 -0
- agent_attrib-0.1.0.dist-info/entry_points.txt +3 -0
agent_attrib/__init__.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""agent-attrib: multi-factor attribution for agent release changes."""
|
|
2
|
+
|
|
3
|
+
from .model import MetricResult, analyze_runset
|
|
4
|
+
from .schema import Design, Run, RunSet, load_runs
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
__all__ = [
|
|
8
|
+
"plan",
|
|
9
|
+
"analyze",
|
|
10
|
+
"load_runs",
|
|
11
|
+
"RunSet",
|
|
12
|
+
"Run",
|
|
13
|
+
"Design",
|
|
14
|
+
"MetricResult",
|
|
15
|
+
"__version__",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def plan(
|
|
20
|
+
factors: list[str],
|
|
21
|
+
tasks: int = 30,
|
|
22
|
+
trials: int = 2,
|
|
23
|
+
seed: int = 42,
|
|
24
|
+
resolution: str = "IV",
|
|
25
|
+
sd: float | None = None,
|
|
26
|
+
) -> "Design":
|
|
27
|
+
"""Generate a factorial experiment design.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
factors: list of factor names.
|
|
31
|
+
tasks: number of distinct tasks.
|
|
32
|
+
trials: trials per configuration per task.
|
|
33
|
+
seed: random seed for run-order randomization.
|
|
34
|
+
resolution: minimum design resolution for fractional designs ("IV" or "V").
|
|
35
|
+
sd: assumed SD for MDE calculation on continuous metrics.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Design object with run order and summary statistics.
|
|
39
|
+
"""
|
|
40
|
+
from .design import build_design, generate_run_order
|
|
41
|
+
from .power import mde_binary, mde_continuous
|
|
42
|
+
from .schema import Design, DesignRow
|
|
43
|
+
|
|
44
|
+
dm_matrix, dm = build_design(factors, tasks=tasks, trials=trials, seed=seed, resolution=resolution)
|
|
45
|
+
task_ids = [f"t{i:04d}" for i in range(tasks)]
|
|
46
|
+
df = generate_run_order(dm_matrix, factors, task_ids, trials, seed)
|
|
47
|
+
|
|
48
|
+
n_configs = dm_matrix.shape[0]
|
|
49
|
+
n_total = n_configs * tasks * trials
|
|
50
|
+
|
|
51
|
+
mde_val = mde_continuous(n_configs, tasks, trials, sd=sd) if sd else mde_binary(n_configs, tasks, trials)
|
|
52
|
+
|
|
53
|
+
rows: list[DesignRow] = []
|
|
54
|
+
for _, row in df.iterrows():
|
|
55
|
+
factor_levels = {f: str(row[f"f_{f}"]) for f in factors}
|
|
56
|
+
rows.append(
|
|
57
|
+
DesignRow(
|
|
58
|
+
config_id=str(row["config_id"]),
|
|
59
|
+
task_id=str(row["task_id"]),
|
|
60
|
+
trial=int(row["trial"]),
|
|
61
|
+
run_order=int(row["run_order"]),
|
|
62
|
+
factors=factor_levels, # type: ignore[arg-type]
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return Design(
|
|
67
|
+
factors=factors,
|
|
68
|
+
tasks=tasks,
|
|
69
|
+
trials=trials,
|
|
70
|
+
seed=seed,
|
|
71
|
+
design_type=dm.design_type,
|
|
72
|
+
resolution=dm.resolution,
|
|
73
|
+
alias_structure=dm.alias_structure,
|
|
74
|
+
runs=rows,
|
|
75
|
+
n_configs=n_configs,
|
|
76
|
+
n_total=n_total,
|
|
77
|
+
mde=mde_val,
|
|
78
|
+
mde_metric="success (binary, p=0.5)" if sd is None else "continuous",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def analyze(
|
|
83
|
+
runset: "RunSet",
|
|
84
|
+
metrics: list[str] | None = None,
|
|
85
|
+
margin: float = 0.05,
|
|
86
|
+
binary_metrics: list[str] | None = None,
|
|
87
|
+
) -> "AnalysisResult":
|
|
88
|
+
"""Analyze a RunSet and return an AnalysisResult.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
runset: loaded runs from load_runs().
|
|
92
|
+
metrics: metrics to analyze (default: all in runset).
|
|
93
|
+
margin: equivalence margin for EQUIVALENT/INCONCLUSIVE verdicts.
|
|
94
|
+
binary_metrics: metric names to treat as binary (default: ["success"]).
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
AnalysisResult with per-metric results and reporting methods.
|
|
98
|
+
"""
|
|
99
|
+
metrics = metrics or runset.metrics
|
|
100
|
+
results = analyze_runset(runset, metrics=metrics, margin=margin, binary_metrics=binary_metrics)
|
|
101
|
+
return AnalysisResult(results)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class AnalysisResult:
|
|
105
|
+
"""Container for per-metric analysis results with reporting methods."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, results: list["MetricResult"]) -> None:
|
|
108
|
+
self.results = results
|
|
109
|
+
|
|
110
|
+
def to_markdown(self) -> str:
|
|
111
|
+
from .report import to_markdown
|
|
112
|
+
return to_markdown(self.results)
|
|
113
|
+
|
|
114
|
+
def to_json(self) -> str:
|
|
115
|
+
from .report import to_json
|
|
116
|
+
return to_json(self.results)
|
|
117
|
+
|
|
118
|
+
def print(self) -> None:
|
|
119
|
+
from .report import print_rich
|
|
120
|
+
print_rich(self.results)
|
|
121
|
+
|
|
122
|
+
def __repr__(self) -> str:
|
|
123
|
+
metrics = [r.metric for r in self.results]
|
|
124
|
+
return f"AnalysisResult(metrics={metrics})"
|
agent_attrib/cli.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Typer CLI: plan, analyze, demo, version."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
name="agent-attrib",
|
|
13
|
+
help="Multi-factor attribution for agent release changes.",
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
)
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command()
|
|
20
|
+
def plan(
|
|
21
|
+
factors: Annotated[
|
|
22
|
+
str,
|
|
23
|
+
typer.Option("--factors", "-f", help="Comma-separated factor names"),
|
|
24
|
+
],
|
|
25
|
+
tasks: Annotated[int, typer.Option("--tasks", "-t", help="Number of tasks")] = 30,
|
|
26
|
+
trials: Annotated[
|
|
27
|
+
int, typer.Option("--trials", help="Trials per config per task")
|
|
28
|
+
] = 2,
|
|
29
|
+
output: Annotated[
|
|
30
|
+
Path | None,
|
|
31
|
+
typer.Option("--output", "-o", help="Write design CSV to this path"),
|
|
32
|
+
] = None,
|
|
33
|
+
seed: Annotated[int, typer.Option("--seed", help="Random seed")] = 42,
|
|
34
|
+
resolution: Annotated[
|
|
35
|
+
str,
|
|
36
|
+
typer.Option("--resolution", help="Minimum resolution for fractional designs (IV or V)"),
|
|
37
|
+
] = "IV",
|
|
38
|
+
sd: Annotated[
|
|
39
|
+
float | None,
|
|
40
|
+
typer.Option("--sd", help="Assumed SD for MDE (continuous metrics)"),
|
|
41
|
+
] = None,
|
|
42
|
+
pilot: Annotated[
|
|
43
|
+
Path | None,
|
|
44
|
+
typer.Option("--pilot", help="Pilot runs JSONL for SD estimation"),
|
|
45
|
+
] = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Generate a factorial experiment design."""
|
|
48
|
+
from .design import build_design, generate_run_order
|
|
49
|
+
from .power import mde_binary, mde_continuous
|
|
50
|
+
from .schema import Design, DesignRow, load_runs
|
|
51
|
+
|
|
52
|
+
factor_list = [f.strip() for f in factors.split(",") if f.strip()]
|
|
53
|
+
if not factor_list:
|
|
54
|
+
console.print("[red]Error: --factors must specify at least one factor[/red]")
|
|
55
|
+
raise typer.Exit(1)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
dm_matrix, dm = build_design(factor_list, tasks=tasks, trials=trials, seed=seed, resolution=resolution)
|
|
59
|
+
except ValueError as e:
|
|
60
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
61
|
+
raise typer.Exit(1) from None
|
|
62
|
+
|
|
63
|
+
task_ids = [f"t{i:04d}" for i in range(tasks)]
|
|
64
|
+
df = generate_run_order(dm_matrix, factor_list, task_ids, trials, seed)
|
|
65
|
+
|
|
66
|
+
n_configs = dm_matrix.shape[0]
|
|
67
|
+
n_total = n_configs * tasks * trials
|
|
68
|
+
|
|
69
|
+
# MDE calculation
|
|
70
|
+
mde_val: float | None = None
|
|
71
|
+
mde_metric_name = "success (binary, p=0.5)"
|
|
72
|
+
if pilot is not None:
|
|
73
|
+
try:
|
|
74
|
+
rs = load_runs(pilot)
|
|
75
|
+
import numpy as np
|
|
76
|
+
vals = [r.metrics.get("success", 0) for r in rs.runs]
|
|
77
|
+
sd_est = float(np.std(vals))
|
|
78
|
+
mde_val = mde_continuous(n_configs, tasks, trials, sd=sd_est)
|
|
79
|
+
mde_metric_name = "success (from pilot)"
|
|
80
|
+
except Exception as e:
|
|
81
|
+
console.print(f"[yellow]Warning: could not read pilot file: {e}[/yellow]")
|
|
82
|
+
elif sd is not None:
|
|
83
|
+
mde_val = mde_continuous(n_configs, tasks, trials, sd=sd)
|
|
84
|
+
mde_metric_name = f"continuous metric (SD={sd})"
|
|
85
|
+
else:
|
|
86
|
+
mde_val = mde_binary(n_configs, tasks, trials)
|
|
87
|
+
|
|
88
|
+
rows: list[DesignRow] = []
|
|
89
|
+
for _, row in df.iterrows():
|
|
90
|
+
factor_levels = {f: str(row[f"f_{f}"]) for f in factor_list}
|
|
91
|
+
rows.append(
|
|
92
|
+
DesignRow(
|
|
93
|
+
config_id=str(row["config_id"]),
|
|
94
|
+
task_id=str(row["task_id"]),
|
|
95
|
+
trial=int(row["trial"]),
|
|
96
|
+
run_order=int(row["run_order"]),
|
|
97
|
+
factors=factor_levels, # type: ignore[arg-type]
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
design = Design(
|
|
102
|
+
factors=factor_list,
|
|
103
|
+
tasks=tasks,
|
|
104
|
+
trials=trials,
|
|
105
|
+
seed=seed,
|
|
106
|
+
design_type=dm.design_type,
|
|
107
|
+
resolution=dm.resolution,
|
|
108
|
+
alias_structure=dm.alias_structure,
|
|
109
|
+
runs=rows,
|
|
110
|
+
n_configs=n_configs,
|
|
111
|
+
n_total=n_total,
|
|
112
|
+
mde=mde_val,
|
|
113
|
+
mde_metric=mde_metric_name,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
console.print(design.summary())
|
|
117
|
+
|
|
118
|
+
if output is not None:
|
|
119
|
+
design.to_csv(output)
|
|
120
|
+
console.print(f"\nDesign written to [cyan]{output}[/cyan]")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.command()
|
|
124
|
+
def analyze(
|
|
125
|
+
runs_file: Annotated[Path, typer.Argument(help="JSONL or CSV runs file")],
|
|
126
|
+
metric: Annotated[
|
|
127
|
+
list[str] | None,
|
|
128
|
+
typer.Option("--metric", "-m", help="Metric to analyze (repeatable)"),
|
|
129
|
+
] = None,
|
|
130
|
+
margin: Annotated[
|
|
131
|
+
float,
|
|
132
|
+
typer.Option("--margin", help="Equivalence margin for verdict"),
|
|
133
|
+
] = 0.05,
|
|
134
|
+
output: Annotated[
|
|
135
|
+
Path | None,
|
|
136
|
+
typer.Option("--output", "-o", help="Write markdown report to this path"),
|
|
137
|
+
] = None,
|
|
138
|
+
json_output: Annotated[
|
|
139
|
+
bool, typer.Option("--json", help="Output JSON instead of markdown")
|
|
140
|
+
] = False,
|
|
141
|
+
binary: Annotated[
|
|
142
|
+
list[str] | None,
|
|
143
|
+
typer.Option("--binary", help="Metric names to treat as binary (default: success)"),
|
|
144
|
+
] = None,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Analyze agent runs and report effect attributions."""
|
|
147
|
+
from .model import analyze_runset
|
|
148
|
+
from .report import print_rich, to_json, to_markdown
|
|
149
|
+
from .schema import load_runs
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
runset = load_runs(runs_file)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
console.print(f"[red]Error loading runs: {e}[/red]")
|
|
155
|
+
raise typer.Exit(1) from None
|
|
156
|
+
|
|
157
|
+
metrics_to_analyze = metric or runset.metrics
|
|
158
|
+
binary_metrics = binary or ["success"]
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
results = analyze_runset(
|
|
162
|
+
runset,
|
|
163
|
+
metrics=metrics_to_analyze,
|
|
164
|
+
margin=margin,
|
|
165
|
+
binary_metrics=binary_metrics,
|
|
166
|
+
)
|
|
167
|
+
except Exception as e:
|
|
168
|
+
console.print(f"[red]Analysis error: {e}[/red]")
|
|
169
|
+
raise typer.Exit(1) from None
|
|
170
|
+
|
|
171
|
+
if json_output:
|
|
172
|
+
from .report import to_json
|
|
173
|
+
text = to_json(results)
|
|
174
|
+
if output:
|
|
175
|
+
output.write_text(text)
|
|
176
|
+
console.print(f"JSON report written to [cyan]{output}[/cyan]")
|
|
177
|
+
else:
|
|
178
|
+
print(text)
|
|
179
|
+
elif output:
|
|
180
|
+
md = to_markdown(results)
|
|
181
|
+
output.write_text(md)
|
|
182
|
+
console.print(f"Markdown report written to [cyan]{output}[/cyan]")
|
|
183
|
+
else:
|
|
184
|
+
print_rich(results, console=console)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.command()
|
|
188
|
+
def demo(
|
|
189
|
+
tasks: Annotated[int, typer.Option("--tasks", "-t")] = 50,
|
|
190
|
+
seed: Annotated[int, typer.Option("--seed")] = 7,
|
|
191
|
+
output: Annotated[Path | None, typer.Option("--output", "-o")] = None,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Run a zero-cost demo with synthetic runs and planted effects."""
|
|
194
|
+
from .model import analyze_runset
|
|
195
|
+
from .report import print_rich, to_markdown
|
|
196
|
+
from .simulate import simulate_multi_metric
|
|
197
|
+
|
|
198
|
+
console.print("[bold cyan]agent-attrib demo[/bold cyan]")
|
|
199
|
+
console.print(
|
|
200
|
+
"Generating synthetic runs with planted effects:\n"
|
|
201
|
+
" prompt: +0.15 on success\n"
|
|
202
|
+
" model: +0.08 on success, −20% on cost\n"
|
|
203
|
+
" retriever: no effect on success, +10% on cost\n"
|
|
204
|
+
" prompt*model interaction: +0.05 on success\n"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
factors = ["prompt", "model", "retriever"]
|
|
208
|
+
runset = simulate_multi_metric(
|
|
209
|
+
factors=factors,
|
|
210
|
+
tasks=tasks,
|
|
211
|
+
trials=2,
|
|
212
|
+
seed=seed,
|
|
213
|
+
success_effects={"prompt": 0.3, "model": 0.16},
|
|
214
|
+
cost_effects={"model": -0.22, "retriever": 0.095},
|
|
215
|
+
success_interaction={("prompt", "model"): 0.10},
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
console.print(
|
|
219
|
+
f"Generated {len(runset.runs)} runs "
|
|
220
|
+
f"({tasks} tasks × {2**3} configs × 2 trials)\n"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
results = analyze_runset(
|
|
224
|
+
runset,
|
|
225
|
+
metrics=["success", "cost_usd"],
|
|
226
|
+
margin=0.05,
|
|
227
|
+
binary_metrics=["success"],
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
print_rich(results, console=console)
|
|
231
|
+
|
|
232
|
+
if output:
|
|
233
|
+
md = to_markdown(results)
|
|
234
|
+
output.write_text(md)
|
|
235
|
+
console.print(f"Report written to [cyan]{output}[/cyan]")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@app.command()
|
|
239
|
+
def version() -> None:
|
|
240
|
+
"""Print the version."""
|
|
241
|
+
from importlib.metadata import version as pkg_version
|
|
242
|
+
try:
|
|
243
|
+
v = pkg_version("agent-attrib")
|
|
244
|
+
except Exception:
|
|
245
|
+
v = "0.1.0 (development)"
|
|
246
|
+
console.print(v)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
if __name__ == "__main__":
|
|
250
|
+
app()
|
agent_attrib/design.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Full and fractional 2-level factorial design generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# Generator tables for regular 2^(k-p) minimum-aberration designs
|
|
13
|
+
# Source: Wu & Hamada "Experiments", Appendix 4B / Table 5A.1
|
|
14
|
+
# Format: {k: {p: (resolution, {added_col_1based: generator_string})}}
|
|
15
|
+
# Generator string: 1-based indices of base columns whose product gives this col.
|
|
16
|
+
# len(added_map) must equal p.
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
#
|
|
19
|
+
# 2^(5-1): only one design exists; E = ABCD → resolution V (word length 5).
|
|
20
|
+
# 2^(6-2): E = ABC, F = BCD → I = ABCE = BCDF = ACDEF → resolution IV.
|
|
21
|
+
# 2^(7-3): E = ABC, F = BCD, G = ACD → all words length 4 → resolution IV.
|
|
22
|
+
_GENERATORS: dict[int, dict[int, tuple[str, dict[int, str]]]] = {
|
|
23
|
+
5: {
|
|
24
|
+
1: (
|
|
25
|
+
"V",
|
|
26
|
+
{5: "1234"}, # E = ABCD → resolution V
|
|
27
|
+
),
|
|
28
|
+
},
|
|
29
|
+
6: {
|
|
30
|
+
2: (
|
|
31
|
+
"IV",
|
|
32
|
+
{5: "123", 6: "234"}, # E = ABC, F = BCD
|
|
33
|
+
),
|
|
34
|
+
},
|
|
35
|
+
7: {
|
|
36
|
+
3: (
|
|
37
|
+
"IV",
|
|
38
|
+
{5: "123", 6: "234", 7: "134"}, # E = ABC, F = BCD, G = ACD
|
|
39
|
+
),
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Resolution V override table (k=5 already gives V; k=6/7 not supported in v0.1)
|
|
44
|
+
_GENERATORS_RES_V: dict[int, dict[int, tuple[str, dict[int, str]]]] = {
|
|
45
|
+
5: {
|
|
46
|
+
1: (
|
|
47
|
+
"V",
|
|
48
|
+
{5: "1234"}, # E = ABCD → resolution V
|
|
49
|
+
),
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class _DesignMatrix:
|
|
56
|
+
matrix: np.ndarray # shape (n_configs, k), values ±1
|
|
57
|
+
factors: list[str]
|
|
58
|
+
resolution: str
|
|
59
|
+
design_type: str
|
|
60
|
+
alias_structure: list[str]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _full_factorial(factors: list[str]) -> _DesignMatrix:
|
|
64
|
+
k = len(factors)
|
|
65
|
+
n = 2**k
|
|
66
|
+
cols = []
|
|
67
|
+
for i in range(k):
|
|
68
|
+
period = 2 ** (k - i - 1)
|
|
69
|
+
col = np.array([(-1) ** (j // period) for j in range(n)], dtype=float)
|
|
70
|
+
cols.append(col)
|
|
71
|
+
matrix = np.column_stack(cols)
|
|
72
|
+
return _DesignMatrix(
|
|
73
|
+
matrix=matrix,
|
|
74
|
+
factors=factors,
|
|
75
|
+
resolution="full",
|
|
76
|
+
design_type=f"2^{k} full factorial",
|
|
77
|
+
alias_structure=[],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _parse_generator(gen_str: str, base_cols: np.ndarray) -> np.ndarray:
|
|
82
|
+
"""Compute added column as product of base columns specified by 1-based indices."""
|
|
83
|
+
col = np.ones(base_cols.shape[0], dtype=float)
|
|
84
|
+
for ch in gen_str:
|
|
85
|
+
col = col * base_cols[:, int(ch) - 1]
|
|
86
|
+
return col
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _fractional_factorial(
|
|
90
|
+
factors: list[str], resolution_req: str = "IV"
|
|
91
|
+
) -> _DesignMatrix:
|
|
92
|
+
k = len(factors)
|
|
93
|
+
|
|
94
|
+
# Pick generator table
|
|
95
|
+
if resolution_req.upper() == "V" and k in _GENERATORS_RES_V:
|
|
96
|
+
gen_table = _GENERATORS_RES_V
|
|
97
|
+
else:
|
|
98
|
+
gen_table = _GENERATORS
|
|
99
|
+
|
|
100
|
+
if k not in gen_table:
|
|
101
|
+
raise ValueError(f"No generator available for k={k}. Supported: {sorted(gen_table)}")
|
|
102
|
+
|
|
103
|
+
p_choices = sorted(gen_table[k].keys())
|
|
104
|
+
res, added_map = gen_table[k][p_choices[0]]
|
|
105
|
+
|
|
106
|
+
# Base design: full factorial on first (k - p) factors
|
|
107
|
+
p = p_choices[0]
|
|
108
|
+
base_k = k - p
|
|
109
|
+
base_dm = _full_factorial(factors[:base_k])
|
|
110
|
+
base_cols = base_dm.matrix # shape (2^base_k, base_k)
|
|
111
|
+
|
|
112
|
+
added_cols = []
|
|
113
|
+
for col_idx in sorted(added_map.keys()):
|
|
114
|
+
gen_str = added_map[col_idx]
|
|
115
|
+
added_cols.append(_parse_generator(gen_str, base_cols))
|
|
116
|
+
|
|
117
|
+
matrix = np.column_stack([base_cols] + [c.reshape(-1, 1) for c in added_cols])
|
|
118
|
+
|
|
119
|
+
alias_structure = _compute_aliases(matrix, factors)
|
|
120
|
+
|
|
121
|
+
return _DesignMatrix(
|
|
122
|
+
matrix=matrix,
|
|
123
|
+
factors=factors,
|
|
124
|
+
resolution=res,
|
|
125
|
+
design_type=f"2^({k}-{p}) fractional factorial",
|
|
126
|
+
alias_structure=alias_structure,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _compute_aliases(matrix: np.ndarray, factors: list[str]) -> list[str]:
|
|
131
|
+
"""Return human-readable alias chains for a fractional design."""
|
|
132
|
+
k = len(factors)
|
|
133
|
+
aliases: list[str] = []
|
|
134
|
+
|
|
135
|
+
# Build all two-factor interactions
|
|
136
|
+
interactions: dict[str, np.ndarray] = {}
|
|
137
|
+
for i, j in itertools.combinations(range(k), 2):
|
|
138
|
+
name = f"{factors[i]}*{factors[j]}"
|
|
139
|
+
interactions[name] = matrix[:, i] * matrix[:, j]
|
|
140
|
+
|
|
141
|
+
# Check which main effects alias with which interactions
|
|
142
|
+
seen: set[str] = set()
|
|
143
|
+
for i in range(k):
|
|
144
|
+
chain = [factors[i]]
|
|
145
|
+
for name, col in interactions.items():
|
|
146
|
+
if np.allclose(matrix[:, i], col) or np.allclose(matrix[:, i], -col):
|
|
147
|
+
chain.append(name)
|
|
148
|
+
seen.add(name)
|
|
149
|
+
if len(chain) > 1:
|
|
150
|
+
aliases.append(" = ".join(chain))
|
|
151
|
+
|
|
152
|
+
# Three-factor interactions could also appear; skip for display brevity
|
|
153
|
+
return aliases
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def build_design(
|
|
157
|
+
factors: list[str],
|
|
158
|
+
tasks: int,
|
|
159
|
+
trials: int,
|
|
160
|
+
seed: int,
|
|
161
|
+
resolution: str = "IV",
|
|
162
|
+
) -> tuple[np.ndarray, _DesignMatrix]:
|
|
163
|
+
"""Return (design_matrix ±1, _DesignMatrix metadata)."""
|
|
164
|
+
if len(factors) < 1:
|
|
165
|
+
raise ValueError("Need at least 1 factor")
|
|
166
|
+
if len(factors) > 7:
|
|
167
|
+
raise ValueError(
|
|
168
|
+
"agent-attrib v0.1 supports up to 7 factors. "
|
|
169
|
+
"For k>7, split into independent sub-experiments."
|
|
170
|
+
)
|
|
171
|
+
if tasks < 1:
|
|
172
|
+
raise ValueError("tasks must be >= 1")
|
|
173
|
+
if trials < 1:
|
|
174
|
+
raise ValueError("trials must be >= 1")
|
|
175
|
+
|
|
176
|
+
if len(factors) <= 4:
|
|
177
|
+
dm = _full_factorial(factors)
|
|
178
|
+
else:
|
|
179
|
+
dm = _fractional_factorial(factors, resolution_req=resolution)
|
|
180
|
+
|
|
181
|
+
return dm.matrix, dm
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def generate_run_order(
|
|
185
|
+
dm_matrix: np.ndarray,
|
|
186
|
+
factors: list[str],
|
|
187
|
+
task_ids: list[str],
|
|
188
|
+
trials: int,
|
|
189
|
+
seed: int,
|
|
190
|
+
) -> pd.DataFrame:
|
|
191
|
+
"""Expand design matrix to per-run rows and randomize order."""
|
|
192
|
+
rng = np.random.default_rng(seed)
|
|
193
|
+
n_configs = dm_matrix.shape[0]
|
|
194
|
+
rows = []
|
|
195
|
+
for ci in range(n_configs):
|
|
196
|
+
config_id = f"c{ci:03d}"
|
|
197
|
+
factor_levels = {
|
|
198
|
+
f: "candidate" if dm_matrix[ci, fi] == 1 else "baseline"
|
|
199
|
+
for fi, f in enumerate(factors)
|
|
200
|
+
}
|
|
201
|
+
for task_id in task_ids:
|
|
202
|
+
for trial in range(1, trials + 1):
|
|
203
|
+
rows.append(
|
|
204
|
+
{
|
|
205
|
+
"config_id": config_id,
|
|
206
|
+
"task_id": task_id,
|
|
207
|
+
"trial": trial,
|
|
208
|
+
**{f"f_{f}": level for f, level in factor_levels.items()},
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
df = pd.DataFrame(rows)
|
|
212
|
+
order = rng.permutation(len(df))
|
|
213
|
+
df["run_order"] = order + 1
|
|
214
|
+
return df.sort_values("run_order").reset_index(drop=True)
|