Seth-Austin / tests /test_analysis.py
stevafernandes's picture
Upload 14 files
451ab90 verified
Raw History Blame Contribute Delete
8.69 kB
import os
import sys
import numpy as np
import pandas as pd
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import analysis as A # noqa: E402
@pytest.fixture(scope="module")
def df():
return A.load_players()
def test_dataset_shape_and_columns(df):
assert len(df) == 148
for col in A.REQUIRED_COLUMNS:
assert col in df.columns
assert df[A.STAT_COLUMNS + [A.SALARY_COLUMN]].isna().sum().sum() == 0
assert df[A.NAME_COLUMN].is_unique
def test_first_player_is_pete_crow_armstrong(df):
assert df.at[0, A.NAME_COLUMN] == "Pete Crow-Armstrong"
assert df.at[0, A.SALARY_COLUMN] == 19166667
def test_percentile_rank_definition():
others = np.array([1, 2, 3, 4])
assert A.percentile_rank(others, 5) == 100.0
assert A.percentile_rank(others, 0) == 0.0
assert A.percentile_rank(others, 2.5) == 50.0
assert A.percentile_rank(others, 2) == 37.5 # one below, one tie counts half
assert A.percentile_rank(others, 0, higher_is_better=False) == 100.0
def test_performance_table_directions(df):
idx = A.player_index(df, "Pete Crow-Armstrong")
table = A.performance_table(df, idx)
assert list(table["Statistic"]) == A.STAT_COLUMNS
war = table.set_index("Statistic").loc["WAR"]
assert war["Rank"] == "1 of 148"
assert war["Percentile"] == 100.0
k = table.set_index("Statistic").loc["K%"]
# Higher strikeout rate than most players, so a low percentile and a negative z-score.
assert k["Percentile"] < 50
assert k["z-score"] < 0
assert 0 <= table["Percentile"].min() and table["Percentile"].max() <= 100
def test_format_helpers():
assert A.format_stat("PA", 436.0) == "436"
assert A.format_stat("K%", 0.261467) == "26.1%"
assert A.format_stat("AVG", 0.28074) == "0.281"
assert A.format_stat("WAR", 5.8616) == "5.9"
assert A.format_stat("wRC+", 144.98) == "145"
assert A.format_money(19166667) == "$19,166,667"
assert A.format_money_short(19166667) == "$19.17M"
assert A.format_money_short(780000) == "$780K"
def test_salary_summary(df):
idx = A.player_index(df, "Pete Crow-Armstrong")
s = A.salary_summary(df, idx)
assert s["n_players"] == 148
assert s["n_market_contracts"] + s["n_minimum_contracts"] == 148
assert s["is_market_contract"] is True
assert 0 < s["salary_percentile"] < 100
assert s["salary_rank"] == int((df[A.SALARY_COLUMN] > 19166667).sum()) + 1
def test_comparables_exclude_self_and_have_k_rows(df):
idx = A.player_index(df, "Pete Crow-Armstrong")
comps, summary = A.comparable_players(df, idx)
assert len(comps) == A.N_COMPARABLES
assert "Pete Crow-Armstrong" not in comps["Player"].tolist()
assert comps["Distance"].astype(float).is_monotonic_increasing
salaries = df.set_index(A.NAME_COLUMN).loc[comps["Player"], A.SALARY_COLUMN]
assert (salaries > A.MARKET_CONTRACT_THRESHOLD).all()
assert summary["comparable_names"] == comps["Player"].tolist()
assert summary["comparable_median_salary"] == float(salaries.median())
def test_cost_per_war_handles_non_positive_war(df):
idx = int(df.index[df["WAR"] <= 0][0])
out = A.cost_per_war(df, idx)
assert out["cost_per_war"] is None
idx = A.player_index(df, "Pete Crow-Armstrong")
out = A.cost_per_war(df, idx)
assert out["cost_per_war"] == pytest.approx(19166667 / df.at[idx, "WAR"])
def test_percentile_within_distribution_interpolates():
q = np.linspace(0, 1, len(A.QUANTILE_GRID))
assert A.percentile_within_distribution(-1, q) == 1.0
assert A.percentile_within_distribution(2, q) == 99.0
assert A.percentile_within_distribution(0.5, q) == pytest.approx(50.0, abs=0.6)
def test_load_players_rejects_bad_data(tmp_path, df):
bad = df.copy()
bad.loc[0, "WAR"] = np.nan
path = tmp_path / "bad.csv"
bad.to_csv(path, index=False)
with pytest.raises(ValueError):
A.load_players(str(path))
dup = df.copy()
dup.loc[1, A.NAME_COLUMN] = dup.loc[0, A.NAME_COLUMN]
dup.to_csv(path, index=False)
with pytest.raises(ValueError):
A.load_players(str(path))
def test_cached_validation_requires_matching_fingerprint(tmp_path, df):
path = tmp_path / "loo.json"
A.save_validation({"fingerprint": "wrong", "n_estimators": A._model_n_estimators()}, str(path))
assert A.load_cached_validation(df, str(path)) is None
A.save_validation({"fingerprint": A.dataset_fingerprint(df), "n_estimators": A._model_n_estimators(), "r2_log_salary": 0.1}, str(path))
assert A.load_cached_validation(df, str(path))["r2_log_salary"] == 0.1
def test_assessment_rules():
def perf_with(pct):
return pd.DataFrame({"Statistic": A.STAT_COLUMNS, "Percentile": [pct] * len(A.STAT_COLUMNS)})
comps = {"comparable_median_salary": 2_000_000}
# Underpaid relative to the model and to rank.
out = A.assessment(perf_with(90.0), {"salary": 1_000_000, "salary_percentile": 30.0},
{"actual_percentile_in_predicted": 10.0}, comps)
assert out["model_band"] == "below"
assert out["headline"].startswith("Performance supports the salary")
assert out["comparables_reading"].startswith("paid less")
# Inside the model range, ranks close.
out = A.assessment(perf_with(60.0), {"salary": 2_100_000, "salary_percentile": 55.0},
{"actual_percentile_in_predicted": 50.0}, comps)
assert out["model_band"] == "inside"
assert out["headline"].startswith("Performance supports the salary")
assert out["comparables_reading"].startswith("paid about the same")
# Overpaid relative to the model and to rank.
out = A.assessment(perf_with(20.0), {"salary": 30_000_000, "salary_percentile": 95.0},
{"actual_percentile_in_predicted": 90.0}, comps)
assert out["model_band"] == "above"
assert out["headline"].startswith("Performance does not support")
assert out["comparables_reading"].startswith("paid more")
# Conflicts produce a mixed reading.
out = A.assessment(perf_with(95.0), {"salary": 30_000_000, "salary_percentile": 60.0},
{"actual_percentile_in_predicted": 90.0}, comps)
assert out["headline"].startswith("The evidence is mixed")
out = A.assessment(perf_with(10.0), {"salary": 5_000_000, "salary_percentile": 60.0},
{"actual_percentile_in_predicted": 40.0}, comps)
assert out["headline"].startswith("The evidence is mixed")
@pytest.mark.slow
def test_model_prediction_for_pete_crow_armstrong(df):
idx = A.player_index(df, "Pete Crow-Armstrong")
out = A.model_salary_for_player(df, idx, n_estimators=4)
assert out["n_train"] == int((df[A.SALARY_COLUMN] > A.MARKET_CONTRACT_THRESHOLD).sum()) - 1
assert out["selected_player_is_market_contract"] is True
q = out["predicted_quantiles"]
assert q["q10"] <= q["q25"] <= q["q50"] <= q["q75"] <= q["q90"]
assert 1.0 <= out["actual_percentile_in_predicted"] <= 99.0
assert out["predicted_median_salary"] > 0
@pytest.mark.slow
def test_analyze_player_bundle_is_json_serializable(df):
result = A.analyze_player(df, "Pete Crow-Armstrong", n_estimators=4)
payload = A.analysis_to_json(result)
assert payload["player"]["name"] == "Pete Crow-Armstrong"
assert len(payload["performance_table"]) == len(A.STAT_COLUMNS)
assert isinstance(payload["assessment"]["headline"], str)
def test_training_mask_excludes_minimum_contracts_and_selected_player(df):
idx = A.player_index(df, "Pete Crow-Armstrong")
mask = A.model_training_mask(df, idx)
assert not mask[idx]
assert (df.loc[mask, A.SALARY_COLUMN] > A.MARKET_CONTRACT_THRESHOLD).all()
minimum_idx = int(df.index[df[A.SALARY_COLUMN] <= A.MARKET_CONTRACT_THRESHOLD][0])
assert not A.model_training_mask(df, minimum_idx)[minimum_idx]
assert A.model_training_mask(df, minimum_idx).sum() == A.model_training_mask(df).sum()
def test_salary_percentile_among_market_is_none_for_minimum_players(df):
minimum_idx = int(df.index[df[A.SALARY_COLUMN] <= A.MARKET_CONTRACT_THRESHOLD][0])
assert A.salary_summary(df, minimum_idx)["salary_percentile_among_market"] is None
idx = A.player_index(df, "Pete Crow-Armstrong")
assert 0 < A.salary_summary(df, idx)["salary_percentile_among_market"] < 100
def test_analysis_to_json_replaces_non_finite_values():
payload = A.analysis_to_json({"a": float("nan"), "b": np.float64("inf"), "c": np.int64(3), "d": [np.float32(1.5)],
"e": pd.DataFrame({"x": [np.nan, 2.0]})})
assert payload == {"a": None, "b": None, "c": 3, "d": [1.5], "e": [{"x": None}, {"x": 2.0}]}