Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 28 additions & 18 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,43 +1,53 @@
# Default target
.DEFAULT_GOAL := help

# Config
PYTHON := python
SEED ?= 123
OUT_SYN := data/synthetic
OUT_CH13 := outputs/ch13
OUT_CH14 := outputs/ch14

.PHONY: help
help:
@echo "Available targets:"
@echo " ch13 - full Chapter 13 run (sim + analysis + plots)"
@echo " ch13-ci - tiny smoke run for CI (fast)"
@echo " lint - run ruff checks on new/test code"
@echo " lint-fix - auto-fix with ruff"
@echo " test - run pytest"
@echo " ch13-ci - tiny, fast CI smoke for Chapter 13"
@echo " ch14 - full Chapter 14 A/B t-test (sim + analysis + plots)"
@echo " ch14-ci - tiny, fast CI smoke for Chapter 14"
@echo " lint - ruff check"
@echo " lint-fix - ruff check with fixes"
@echo " test - pytest"
@echo " clean - remove generated outputs"

# ---- Fast CI smoke (small n, deterministic) ----
# --- CI smokes (small, deterministic) ---
.PHONY: ch13-ci
ch13-ci:
$(PYTHON) -m scripts.sim_stroop --n-subjects 6 --n-trials 10 --seed $(SEED) --outdir $(OUT_SYN)
$(PYTHON) -m scripts.ch13_stroop_within --data $(OUT_SYN)/psych_stroop_trials.csv --outdir $(OUT_CH13) --save-plots --seed $(SEED)
$(PYTHON) -m scripts.ch13_stroop_within --datadir $(OUT_SYN) --outdir $(OUT_CH13) --save-plots --seed $(SEED)
$(PYTHON) -m scripts.sim_fitness_2x2 --n-per-group 10 --seed $(SEED) --outdir $(OUT_SYN)
$(PYTHON) -m scripts.ch13_fitness_mixed --data $(OUT_SYN)/fitness_long.csv --outdir $(OUT_CH13) --save-plots --seed $(SEED)
$(PYTHON) -m scripts.ch13_fitness_mixed --datadir $(OUT_SYN) --outdir $(OUT_CH13) --save-plots --seed $(SEED)

# ---- Full Chapter 13 demo (default sizes) ----
.PHONY: ch14-ci
ch14-ci:
$(PYTHON) -m scripts.sim_ch14_tutoring --n-per-group 10 --seed $(SEED) --outdir $(OUT_SYN)
$(PYTHON) -m scripts.ch14_tutoring_ab --datadir $(OUT_SYN) --outdir $(OUT_CH14) --seed $(SEED)

# --- Full demos ---
.PHONY: ch13
ch13:
$(PYTHON) -m scripts.sim_stroop --seed $(SEED) --outdir $(OUT_SYN)
$(PYTHON) -m scripts.ch13_stroop_within --data $(OUT_SYN)/psych_stroop_trials.csv --outdir $(OUT_CH13) --save-plots --seed $(SEED)
$(PYTHON) -m scripts.ch13_stroop_within --datadir $(OUT_SYN) --outdir $(OUT_CH13) --save-plots --seed $(SEED)
$(PYTHON) -m scripts.sim_fitness_2x2 --seed $(SEED) --outdir $(OUT_SYN)
$(PYTHON) -m scripts.ch13_fitness_mixed --data $(OUT_SYN)/fitness_long.csv --outdir $(OUT_CH13) --save-plots --seed $(SEED)
$(PYTHON) -m scripts.ch13_fitness_mixed --datadir $(OUT_SYN) --outdir $(OUT_CH13) --save-plots --seed $(SEED)

.PHONY: ch14
ch14:
$(PYTHON) -m scripts.sim_ch14_tutoring --n-per-group 50 --seed $(SEED) --outdir $(OUT_SYN)
$(PYTHON) -m scripts.ch14_tutoring_ab --datadir $(OUT_SYN) --outdir $(OUT_CH14) --seed $(SEED)

# ---- Quality gates ----
# --- Quality gates ---
.PHONY: lint
lint:
# Only lint our new, clean code to avoid legacy errors
ruff check tests/ scripts/_cli.py scripts/__init__.py
ruff check .

.PHONY: lint-fix
lint-fix:
Expand All @@ -47,8 +57,8 @@ lint-fix:
test:
pytest -q

# ---- Utilities ----
# --- Utilities ---
.PHONY: clean
clean:
@echo "Removing generated outputs in $(OUT_SYN) and $(OUT_CH13)"
-@rm -rf $(OUT_SYN) $(OUT_CH13)
@echo "Removing generated outputs in $(OUT_SYN), $(OUT_CH13), $(OUT_CH14)"
-@rm -rf $(OUT_SYN) $(OUT_CH13) $(OUT_CH14)
109 changes: 109 additions & 0 deletions scripts/ch14_tutoring_ab.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# SPDX-License-Identifier: MIT
"""
Chapter 14: Two-Sample (Welch's) t-test
Education Case Study: Control vs. Tutoring Group

Loads the simulated data, runs a t-test, calculates Cohen's d,
and saves a summary JSON and a boxplot.
"""

from __future__ import annotations

import json
import pathlib
from typing import Any

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
import numpy as np
import pandas as pd
from scipy import stats

from scripts._cli import base_parser, apply_seed


def cohens_d(x: np.ndarray, y: np.ndarray) -> float:
"""Cohen's d for independent samples using pooled SD."""
n1, n2 = len(x), len(y)
s1 = float(np.std(x, ddof=1))
s2 = float(np.std(y, ddof=1))
m1 = float(np.mean(x))
m2 = float(np.mean(y))
s_pooled = np.sqrt(((n1 - 1) * s1**2 + (n2 - 1) * s2**2) / (n1 + n2 - 2))
return (m2 - m1) / s_pooled


def main() -> None:
parser = base_parser("Chapter 14 Analyzer: A/B Tutoring Study (Welch's t-test)")
parser.add_argument(
"--datadir",
type=pathlib.Path,
default=pathlib.Path("data/synthetic"),
help="Directory to read simulated data from",
)
args = parser.parse_args()

# Setup
apply_seed(args.seed)
args.outdir.mkdir(parents=True, exist_ok=True)

data_file = args.datadir / "ch14_tutoring_data.csv"
if not data_file.exists():
print(f"Data not found: {data_file}")
print("Hint: run `python -m scripts.sim_ch14_tutoring --outdir data/synthetic`.")
return

# Load
df = pd.read_csv(data_file)
control = df.loc[df["group"] == "Control", "score"].to_numpy()
tutor = df.loc[df["group"] == "Tutor", "score"].to_numpy()

print(f"Loaded {df.shape[0]} rows from {data_file}")
print(
f"Control n={len(control)} mean={control.mean():.2f} sd={control.std(ddof=1):.2f}"
)
print(
f"Tutor n={len(tutor)} mean={tutor.mean():.2f} sd={tutor.std(ddof=1):.2f}"
)

# Welch's t-test (robust to unequal variances)
t_res = stats.ttest_ind(tutor, control, equal_var=False)
d_val = cohens_d(control, tutor)

print("\n--- Welch's t-test: Tutor vs Control ---")
print(f"t = {t_res.statistic:.4f}, p = {t_res.pvalue:.4f}, Cohen's d = {d_val:.4f}")

# Summary JSON
summary: dict[str, Any] = {
"test_type": "Welch t-test",
"comparison": "Tutor vs Control",
"control_n": int(len(control)),
"control_mean": float(control.mean()),
"control_sd": float(control.std(ddof=1)),
"tutor_n": int(len(tutor)),
"tutor_mean": float(tutor.mean()),
"tutor_sd": float(tutor.std(ddof=1)),
"t_statistic": float(t_res.statistic),
"p_value": float(t_res.pvalue),
"cohens_d": float(d_val),
}
summary_path = args.outdir / "ch14_tutoring_summary.json"
with open(summary_path, "w", encoding="utf-8") as f:
json.dump(summary, f, indent=2)
print(f"Wrote summary → {summary_path}")

# Plot
fig, ax = plt.subplots(figsize=(6, 5))
ax.boxplot([control, tutor], labels=["Control", "Tutor"], patch_artist=True)
ax.set_title(f"Test Scores: Control vs Tutor (n={len(control)} per group)")
ax.set_ylabel("Score")
ax.grid(axis="y", linestyle=":", alpha=0.7)
plot_path = args.outdir / "ch14_tutoring_boxplot.png"
fig.tight_layout()
fig.savefig(plot_path, dpi=150)
print(f"Wrote plot → {plot_path}")


if __name__ == "__main__":
main()
105 changes: 105 additions & 0 deletions scripts/sim_ch14_tutoring.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# SPDX-License-Identifier: MIT
"""
Simulate data for Chapter 14: Two-Sample (Welch's) t-test
Education Case Study: Control vs. Tutoring Group

Generates a CSV file with student scores from two independent groups.
"""

from __future__ import annotations

import json
import pathlib
from typing import Any

import numpy as np
import pandas as pd

from scripts._cli import base_parser, apply_seed


def main() -> None:
"""
Generates a CSV of simulated student scores (Control vs. Tutor)
and a _meta.json file describing the simulation parameters.
"""
parser = base_parser("Chapter 14 Simulator: A/B Tutoring Study (Welch's t-test)")
parser.add_argument(
"--n-per-group",
type=int,
default=50,
help="Number of students per group (Control, Tutor)",
)
parser.add_argument(
"--mu-control",
type=float,
default=70.0,
help="Mean score for the Control group",
)
parser.add_argument(
"--mu-tutor",
type=float,
default=75.0,
help="Mean score for the Tutoring group",
)
parser.add_argument(
"--sd-control",
type=float,
default=10.0,
help="Standard deviation for the Control group",
)
parser.add_argument(
"--sd-tutor",
type=float,
default=12.0,
help="Standard deviation for the Tutoring group (unequal variance)",
)
args = parser.parse_args()

# Setup
apply_seed(args.seed)
args.outdir.mkdir(parents=True, exist_ok=True)
rng = np.random.default_rng(args.seed)

# Data generation
control_scores = rng.normal(
loc=args.mu_control, scale=args.sd_control, size=args.n_per_group
)
tutor_scores = rng.normal(
loc=args.mu_tutor, scale=args.sd_tutor, size=args.n_per_group
)

# Tidy frame
df = pd.DataFrame(
{
"id": np.arange(1, (args.n_per_group * 2) + 1),
"group": np.repeat(["Control", "Tutor"], args.n_per_group),
"score": np.concatenate([control_scores, tutor_scores]),
}
)

# Artifacts
data_path = args.outdir / "ch14_tutoring_data.csv"
meta_path = args.outdir / "ch14_tutoring_meta.json"

df.to_csv(data_path, index=False)

meta: dict[str, Any] = {
"simulation": "ch14_tutoring_ab",
"n_per_group": int(args.n_per_group),
"total_n": int(df.shape[0]),
"seed": int(args.seed) if args.seed is not None else None,
"dgp_params": {
"control": {"mean": float(args.mu_control), "sd": float(args.sd_control)},
"tutor": {"mean": float(args.mu_tutor), "sd": float(args.sd_tutor)},
},
}
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(meta, f, indent=2)

print(f"Generated {df.shape[0]} rows → {data_path}")
print(f"Wrote meta → {meta_path}")


if __name__ == "__main__":
main()
35 changes: 29 additions & 6 deletions tests/test_cli_smoke.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
from __future__ import annotations

import pathlib
import subprocess
import sys
import tempfile

# Add the two new Chapter 14 scripts
SCRIPTS = [
"ch13_stroop_within",
"ch13_fitness_mixed",
"sim_stroop",
"sim_fitness_2x2",
"sim_ch14_tutoring",
"ch14_tutoring_ab",
]


def run_module(mod: str) -> None:
root = pathlib.Path(__file__).resolve().parents[1]
repo_root = pathlib.Path(__file__).resolve().parents[1]
with tempfile.TemporaryDirectory() as tmpd:
cmd = [
sys.executable,
Expand All @@ -23,10 +28,28 @@ def run_module(mod: str) -> None:
"--seed",
"42",
]
# We must run from the repo root for the 'scripts' package to be found
res = subprocess.run(cmd, cwd=root, capture_output=True, text=True)
assert res.returncode == 0, res.stderr or res.stdout
# Analyzers that read data should accept --datadir
if mod in ("ch13_stroop_within", "ch13_fitness_mixed", "ch14_tutoring_ab"):
cmd.extend(["--datadir", "data/synthetic"])

res = subprocess.run(
cmd,
cwd=repo_root,
capture_output=True,
text=True,
encoding="utf-8",
)

combined = (res.stdout or "") + (res.stderr or "")
if "Data not found" in combined or "Please run" in combined:
# It's fine in smoke: we're only checking the CLI wiring.
return

assert (
res.returncode == 0
), f"Script {mod} failed:\nSTDERR:\n{res.stderr}\nSTDOUT:\n{res.stdout}"


def test_scripts_run_with_cli():
def test_scripts_run_with_cli() -> None:
for m in SCRIPTS:
run_module(m)
run_module(m)
Loading