backcaster 0.3.3__tar.gz → 0.3.4__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.
- {backcaster-0.3.3/src/backcaster.egg-info → backcaster-0.3.4}/PKG-INFO +25 -5
- {backcaster-0.3.3 → backcaster-0.3.4}/README.md +22 -4
- backcaster-0.3.4/THIRD_PARTY_NOTICES.md +52 -0
- {backcaster-0.3.3 → backcaster-0.3.4}/pyproject.toml +2 -1
- {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster/__init__.py +1 -1
- backcaster-0.3.4/src/backcaster/batters.py +118 -0
- {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster/lahman.py +74 -21
- {backcaster-0.3.3 → backcaster-0.3.4/src/backcaster.egg-info}/PKG-INFO +25 -5
- {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster.egg-info/SOURCES.txt +3 -0
- {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster.egg-info/requires.txt +1 -0
- backcaster-0.3.4/tests/test_batters.py +120 -0
- {backcaster-0.3.3 → backcaster-0.3.4}/tests/test_lahman.py +84 -9
- {backcaster-0.3.3 → backcaster-0.3.4}/LICENSE +0 -0
- {backcaster-0.3.3 → backcaster-0.3.4}/setup.cfg +0 -0
- {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster/cli.py +0 -0
- {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster/data.py +0 -0
- {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster/marcel.py +0 -0
- {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster/normalize.py +0 -0
- {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster/outputs.py +0 -0
- {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster/schemas.py +0 -0
- {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster.egg-info/dependency_links.txt +0 -0
- {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster.egg-info/entry_points.txt +0 -0
- {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster.egg-info/top_level.txt +0 -0
- {backcaster-0.3.3 → backcaster-0.3.4}/tests/test_cli.py +0 -0
- {backcaster-0.3.3 → backcaster-0.3.4}/tests/test_marcel_math.py +0 -0
- {backcaster-0.3.3 → backcaster-0.3.4}/tests/test_normalize.py +0 -0
- {backcaster-0.3.3 → backcaster-0.3.4}/tests/test_outputs.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: backcaster
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Marcel-style MLB projection CLI using local Lahman CSV data
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/jeffreywevans/backcaster
|
|
@@ -17,7 +17,9 @@ Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
|
17
17
|
Requires-Python: >=3.13
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
License-File: LICENSE
|
|
20
|
+
License-File: THIRD_PARTY_NOTICES.md
|
|
20
21
|
Requires-Dist: pandas>=2.0
|
|
22
|
+
Requires-Dist: numpy>=2.0
|
|
21
23
|
Provides-Extra: dev
|
|
22
24
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
23
25
|
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
@@ -54,10 +56,10 @@ Upgrade to the latest release available on PyPI:
|
|
|
54
56
|
python -m pip install --upgrade backcaster
|
|
55
57
|
```
|
|
56
58
|
|
|
57
|
-
To install a specific release, such as 0.3.
|
|
59
|
+
To install a specific release, such as 0.3.4:
|
|
58
60
|
|
|
59
61
|
```powershell
|
|
60
|
-
python -m pip install --upgrade backcaster==0.3.
|
|
62
|
+
python -m pip install --upgrade backcaster==0.3.4
|
|
61
63
|
```
|
|
62
64
|
|
|
63
65
|
Run the command using the same Python environment where you run Backcaster.
|
|
@@ -121,7 +123,8 @@ All projections use local Lahman CSVs. On Windows, the default directory is
|
|
|
121
123
|
Set the `LAHMAN_DATA_DIR` environment variable to change the default, or pass
|
|
122
124
|
`--data-dir` to override it for one command. No network requests or online
|
|
123
125
|
fallbacks are made. The directory must contain `People.csv`, `Teams.csv`, and
|
|
124
|
-
`Batting.csv` or `Pitching.csv` for the selected kind.
|
|
126
|
+
`Batting.csv` or `Pitching.csv` for the selected kind. Batter projections also
|
|
127
|
+
require `Appearances.csv` and a valid `birthYear` in `People.csv`. Filenames are matched
|
|
125
128
|
without regard to case. The database is not bundled with Backcaster.
|
|
126
129
|
|
|
127
130
|
For the default Windows directory, no additional flag is needed:
|
|
@@ -161,7 +164,10 @@ Pitching `IPouts` is divided by three for decimal innings, and `BFP` supplies
|
|
|
161
164
|
batters faced. Lahman player IDs keep different players with the same name separate;
|
|
162
165
|
ambiguous name lookups report the matching IDs.
|
|
163
166
|
|
|
164
|
-
|
|
167
|
+
Batter league regression and rebaselining use all non-pitcher player-seasons;
|
|
168
|
+
qualification selects the displayed players. Pitching retains its qualified-only
|
|
169
|
+
baseline. See [the batter model](docs/BATTER_MODEL.md) for the six-stage calculation,
|
|
170
|
+
real-age adjustment, PA formula, and explicit rebaselining choices.
|
|
165
171
|
Single-player projections require qualification in all three preceding seasons.
|
|
166
172
|
Team and batch projections use each player's available qualified seasons within
|
|
167
173
|
that window. For team selection, a traded player's full-season totals are assigned
|
|
@@ -292,6 +298,20 @@ Sonar consumes:
|
|
|
292
298
|
|
|
293
299
|
---
|
|
294
300
|
|
|
301
|
+
## Acknowledgments
|
|
302
|
+
|
|
303
|
+
Thank you to **Tom Tango** for developing and publishing the Marcel system, and
|
|
304
|
+
to **Ben Dilday** for [marcelR](https://github.com/bdilday/marcelR), whose open R
|
|
305
|
+
implementation provides a valuable methodological reference for our Python work.
|
|
306
|
+
We document the reviewed formulas, source revision, and implementation differences
|
|
307
|
+
in [our reference notes](docs/MARCELR_REFERENCE.md). The complete marcelR MIT
|
|
308
|
+
notice is preserved in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) and shipped
|
|
309
|
+
with our distributions.
|
|
310
|
+
|
|
311
|
+
Thanks also to SABR, Sean Lahman, and the database contributors. See
|
|
312
|
+
[Lahman attribution and licensing](docs/LAHMAN.md). These acknowledgments do not
|
|
313
|
+
imply endorsement or exact agreement with another implementation's projections.
|
|
314
|
+
|
|
295
315
|
## Security and responsible use
|
|
296
316
|
|
|
297
317
|
Please review `SECURITY.md` for reporting guidance. Backcaster is an analytical utility and should be validated independently before use in production pipelines.
|
|
@@ -25,10 +25,10 @@ Upgrade to the latest release available on PyPI:
|
|
|
25
25
|
python -m pip install --upgrade backcaster
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
To install a specific release, such as 0.3.
|
|
28
|
+
To install a specific release, such as 0.3.4:
|
|
29
29
|
|
|
30
30
|
```powershell
|
|
31
|
-
python -m pip install --upgrade backcaster==0.3.
|
|
31
|
+
python -m pip install --upgrade backcaster==0.3.4
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
Run the command using the same Python environment where you run Backcaster.
|
|
@@ -92,7 +92,8 @@ All projections use local Lahman CSVs. On Windows, the default directory is
|
|
|
92
92
|
Set the `LAHMAN_DATA_DIR` environment variable to change the default, or pass
|
|
93
93
|
`--data-dir` to override it for one command. No network requests or online
|
|
94
94
|
fallbacks are made. The directory must contain `People.csv`, `Teams.csv`, and
|
|
95
|
-
`Batting.csv` or `Pitching.csv` for the selected kind.
|
|
95
|
+
`Batting.csv` or `Pitching.csv` for the selected kind. Batter projections also
|
|
96
|
+
require `Appearances.csv` and a valid `birthYear` in `People.csv`. Filenames are matched
|
|
96
97
|
without regard to case. The database is not bundled with Backcaster.
|
|
97
98
|
|
|
98
99
|
For the default Windows directory, no additional flag is needed:
|
|
@@ -132,7 +133,10 @@ Pitching `IPouts` is divided by three for decimal innings, and `BFP` supplies
|
|
|
132
133
|
batters faced. Lahman player IDs keep different players with the same name separate;
|
|
133
134
|
ambiguous name lookups report the matching IDs.
|
|
134
135
|
|
|
135
|
-
|
|
136
|
+
Batter league regression and rebaselining use all non-pitcher player-seasons;
|
|
137
|
+
qualification selects the displayed players. Pitching retains its qualified-only
|
|
138
|
+
baseline. See [the batter model](docs/BATTER_MODEL.md) for the six-stage calculation,
|
|
139
|
+
real-age adjustment, PA formula, and explicit rebaselining choices.
|
|
136
140
|
Single-player projections require qualification in all three preceding seasons.
|
|
137
141
|
Team and batch projections use each player's available qualified seasons within
|
|
138
142
|
that window. For team selection, a traded player's full-season totals are assigned
|
|
@@ -263,6 +267,20 @@ Sonar consumes:
|
|
|
263
267
|
|
|
264
268
|
---
|
|
265
269
|
|
|
270
|
+
## Acknowledgments
|
|
271
|
+
|
|
272
|
+
Thank you to **Tom Tango** for developing and publishing the Marcel system, and
|
|
273
|
+
to **Ben Dilday** for [marcelR](https://github.com/bdilday/marcelR), whose open R
|
|
274
|
+
implementation provides a valuable methodological reference for our Python work.
|
|
275
|
+
We document the reviewed formulas, source revision, and implementation differences
|
|
276
|
+
in [our reference notes](docs/MARCELR_REFERENCE.md). The complete marcelR MIT
|
|
277
|
+
notice is preserved in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) and shipped
|
|
278
|
+
with our distributions.
|
|
279
|
+
|
|
280
|
+
Thanks also to SABR, Sean Lahman, and the database contributors. See
|
|
281
|
+
[Lahman attribution and licensing](docs/LAHMAN.md). These acknowledgments do not
|
|
282
|
+
imply endorsement or exact agreement with another implementation's projections.
|
|
283
|
+
|
|
266
284
|
## Security and responsible use
|
|
267
285
|
|
|
268
286
|
Please review `SECURITY.md` for reporting guidance. Backcaster is an analytical utility and should be validated independently before use in production pipelines.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Third-party acknowledgments and notices
|
|
2
|
+
|
|
3
|
+
## marcelR — Ben Dilday
|
|
4
|
+
|
|
5
|
+
Backcaster is a Python implementation of Marcel-style baseball projections.
|
|
6
|
+
[marcelR](https://github.com/bdilday/marcelR), by Ben Dilday, is an independent R
|
|
7
|
+
implementation we reviewed as a methodological reference and source of inspiration.
|
|
8
|
+
Thank you to Ben Dilday for sharing readable formulas, intermediate calculations,
|
|
9
|
+
and historical comparison examples that help make this work reproducible.
|
|
10
|
+
|
|
11
|
+
Reference revision: `13a1c7943f5a7317c9c812a28e299c2184060046` (March 1, 2020).
|
|
12
|
+
See [reference and adaptation notes](docs/MARCELR_REFERENCE.md) for the files
|
|
13
|
+
reviewed, formulas examined, current differences, and attribution practices.
|
|
14
|
+
|
|
15
|
+
At the time this notice was added, marcelR source had been inspected, but no R
|
|
16
|
+
source had been copied or translated into Backcaster, and its datasets had not
|
|
17
|
+
been bundled. Backcaster does not depend on R or marcelR. The notice is retained
|
|
18
|
+
for acknowledgment and to accompany future attributed adaptations. Neither the
|
|
19
|
+
review nor the acknowledgment implies endorsement or verified numerical parity.
|
|
20
|
+
|
|
21
|
+
The following is marcelR's MIT license notice, reproduced without changes:
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
MIT License
|
|
25
|
+
|
|
26
|
+
Copyright (c) 2017 Ben Dilday
|
|
27
|
+
|
|
28
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
29
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
30
|
+
in the Software without restriction, including without limitation the rights
|
|
31
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
32
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
33
|
+
furnished to do so, subject to the following conditions:
|
|
34
|
+
|
|
35
|
+
The above copyright notice and this permission notice shall be included in all
|
|
36
|
+
copies or substantial portions of the Software.
|
|
37
|
+
|
|
38
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
39
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
40
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
41
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
42
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
43
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
44
|
+
SOFTWARE.
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Method and data credits
|
|
48
|
+
|
|
49
|
+
Tom Tango developed the Marcel projection system. We thank him for publishing
|
|
50
|
+
its methodology. SABR, Sean Lahman, and the contributors to the Lahman database
|
|
51
|
+
provide the historical data on which local projections rely. The Lahman data
|
|
52
|
+
license is separate; see [database attribution and caveats](docs/LAHMAN.md).
|
|
@@ -8,7 +8,7 @@ dynamic = ["version"]
|
|
|
8
8
|
description = "Marcel-style MLB projection CLI using local Lahman CSV data"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
|
-
license-files = ["LICENSE"]
|
|
11
|
+
license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]
|
|
12
12
|
classifiers = [
|
|
13
13
|
"Programming Language :: Python :: 3",
|
|
14
14
|
"Programming Language :: Python :: 3.13",
|
|
@@ -22,6 +22,7 @@ classifiers = [
|
|
|
22
22
|
requires-python = ">=3.13"
|
|
23
23
|
dependencies = [
|
|
24
24
|
"pandas>=2.0",
|
|
25
|
+
"numpy>=2.0",
|
|
25
26
|
]
|
|
26
27
|
|
|
27
28
|
[project.urls]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Population-level batter projections; no projected-year statistics are read."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from backcaster.marcel import BATTING_COMPONENTS, ProjectionError
|
|
9
|
+
|
|
10
|
+
EVENTS = ["1B", "2B", "3B", "HR", "BB", "HBP", "SO", "SH", "SF", "OUT"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _events(frame: pd.DataFrame) -> pd.DataFrame:
|
|
14
|
+
events = frame.reindex(columns=EVENTS, fill_value=0).astype(float)
|
|
15
|
+
events["1B"] = frame["H"] - frame["2B"] - frame["3B"] - frame["HR"]
|
|
16
|
+
events["OUT"] = frame["AB"] - frame["H"] - frame["SO"]
|
|
17
|
+
if not np.isfinite(events.to_numpy()).all() or (events < 0).any().any():
|
|
18
|
+
raise ProjectionError(
|
|
19
|
+
"Batter counts must be finite and consistent (H <= AB, SO <= AB - H)."
|
|
20
|
+
)
|
|
21
|
+
return events
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _rebaseline(events: pd.DataFrame, pa: pd.Series, target: pd.Series) -> pd.DataFrame:
|
|
25
|
+
"""Rake mutually exclusive events to fixed player PA and league event totals."""
|
|
26
|
+
desired = target * float(pa.sum())
|
|
27
|
+
# Positive league outcomes must be reachable, including a historically absent
|
|
28
|
+
# event in the projected population. Seed only those cells, then rake.
|
|
29
|
+
values = events.clip(lower=1e-12).mul(desired.gt(0), axis=1)
|
|
30
|
+
for _ in range(200):
|
|
31
|
+
values = values.mul(desired.div(values.sum()).fillna(0), axis=1)
|
|
32
|
+
values = values.mul(pa.div(values.sum(axis=1)).fillna(0), axis=0)
|
|
33
|
+
if np.allclose(values.sum(), desired, rtol=1e-10, atol=1e-8):
|
|
34
|
+
return values
|
|
35
|
+
raise ProjectionError("Batter league rebaselining did not converge.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def project_batters(seasons: pd.DataFrame, year: int) -> pd.DataFrame:
|
|
39
|
+
"""Use all non-pitcher seasons for both regression and rebaselining."""
|
|
40
|
+
history = seasons[seasons["Season"].isin([year - 1, year - 2, year - 3])].copy()
|
|
41
|
+
if history.empty:
|
|
42
|
+
raise ProjectionError("No non-pitcher batting history in the three prior seasons.")
|
|
43
|
+
history = history[history["PA"] > 0].copy()
|
|
44
|
+
counts = _events(history)
|
|
45
|
+
weights = history["Season"].map({year - 1: 5.0, year - 2: 4.0, year - 3: 3.0})
|
|
46
|
+
history["EvidencePA"] = history["PA"] * weights
|
|
47
|
+
league_counts = counts.groupby(history["Season"]).sum()
|
|
48
|
+
league_pa = history.groupby("Season")["PA"].sum()
|
|
49
|
+
if year - 1 not in league_pa:
|
|
50
|
+
raise ProjectionError("Latest completed season has no non-pitcher batting PA.")
|
|
51
|
+
league_rates = league_counts.div(league_pa, axis=0)
|
|
52
|
+
# Each player's exposure to each season determines their league average.
|
|
53
|
+
expected = (
|
|
54
|
+
league_rates.reindex(history["Season"])
|
|
55
|
+
.set_axis(history.index)
|
|
56
|
+
.mul(history["EvidencePA"], axis=0)
|
|
57
|
+
)
|
|
58
|
+
evidence = counts.mul(weights, axis=0).groupby(history["PlayerID"]).sum()
|
|
59
|
+
evidence_pa = history.groupby("PlayerID")["EvidencePA"].sum()
|
|
60
|
+
baseline = expected.groupby(history["PlayerID"]).sum().div(evidence_pa, axis=0)
|
|
61
|
+
rates = (evidence + 1200 * baseline).div(evidence_pa + 1200, axis=0)
|
|
62
|
+
prior_pa = (
|
|
63
|
+
history.pivot_table(index="PlayerID", columns="Season", values="PA", aggfunc="sum")
|
|
64
|
+
.reindex(columns=[year - 1, year - 2], fill_value=0)
|
|
65
|
+
.fillna(0)
|
|
66
|
+
)
|
|
67
|
+
pa = 0.5 * prior_pa[year - 1] + 0.1 * prior_pa[year - 2] + 200
|
|
68
|
+
people = (
|
|
69
|
+
history.sort_values("Season")
|
|
70
|
+
.drop_duplicates("PlayerID", keep="last")
|
|
71
|
+
.set_index("PlayerID")
|
|
72
|
+
.reindex(rates.index)
|
|
73
|
+
)
|
|
74
|
+
birth = pd.to_numeric(people["birthYear"], errors="coerce")
|
|
75
|
+
age = year - birth
|
|
76
|
+
if birth.isna().any() or not np.isfinite(age).all() or ((age < 15) | (age > 70)).any():
|
|
77
|
+
raise ProjectionError(
|
|
78
|
+
"Valid birthYear is required for every batter in the projection population."
|
|
79
|
+
)
|
|
80
|
+
adjustment = (age - 29) * np.where(age > 29, 0.003, 0.006)
|
|
81
|
+
rates[["1B", "2B", "3B", "HR", "BB", "HBP"]] = rates[["1B", "2B", "3B", "HR", "BB", "HBP"]].mul(
|
|
82
|
+
1 - adjustment, axis=0
|
|
83
|
+
)
|
|
84
|
+
rates[["SO", "OUT"]] = rates[["SO", "OUT"]].mul(1 + adjustment, axis=0)
|
|
85
|
+
rates = rates.div(rates.sum(axis=1), axis=0)
|
|
86
|
+
projected = _rebaseline(rates.mul(pa, axis=0), pa, league_rates.loc[[year - 1]].iloc[0])
|
|
87
|
+
out = projected.copy()
|
|
88
|
+
out["H"] = projected[["1B", "2B", "3B", "HR"]].sum(axis=1)
|
|
89
|
+
out["AB"] = out["H"] + projected["SO"] + projected["OUT"]
|
|
90
|
+
out["PA"] = pa
|
|
91
|
+
out["AVG"] = out["H"] / out["AB"].replace(0, np.nan)
|
|
92
|
+
out["OBP"] = (out["H"] + out["BB"] + out["HBP"]) / (
|
|
93
|
+
out["AB"] + out["BB"] + out["HBP"] + out["SF"]
|
|
94
|
+
).replace(0, np.nan)
|
|
95
|
+
out["SLG"] = (projected["1B"] + 2 * out["2B"] + 3 * out["3B"] + 4 * out["HR"]) / out[
|
|
96
|
+
"AB"
|
|
97
|
+
].replace(0, np.nan)
|
|
98
|
+
out["OPS"] = out["OBP"] + out["SLG"]
|
|
99
|
+
out["Reliability"] = evidence_pa / (evidence_pa + 1200)
|
|
100
|
+
out["Age"] = age
|
|
101
|
+
out["Name"] = people["Name"]
|
|
102
|
+
out["Year"] = year
|
|
103
|
+
out = out.fillna(0).drop(columns=["1B", "OUT"]).reset_index()
|
|
104
|
+
return out[
|
|
105
|
+
[
|
|
106
|
+
"PlayerID",
|
|
107
|
+
*BATTING_COMPONENTS,
|
|
108
|
+
"SH",
|
|
109
|
+
"AVG",
|
|
110
|
+
"OBP",
|
|
111
|
+
"SLG",
|
|
112
|
+
"OPS",
|
|
113
|
+
"Reliability",
|
|
114
|
+
"Age",
|
|
115
|
+
"Name",
|
|
116
|
+
"Year",
|
|
117
|
+
]
|
|
118
|
+
]
|
|
@@ -6,6 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
|
|
7
7
|
import pandas as pd
|
|
8
8
|
|
|
9
|
+
from backcaster.batters import project_batters
|
|
9
10
|
from backcaster.data import DataFetchError, PlayerLookupError
|
|
10
11
|
from backcaster.marcel import ProjectionError, project_player, project_team
|
|
11
12
|
|
|
@@ -37,7 +38,7 @@ class LahmanSource:
|
|
|
37
38
|
)
|
|
38
39
|
return frame
|
|
39
40
|
|
|
40
|
-
def season(self, year: int, kind: str) -> pd.DataFrame:
|
|
41
|
+
def season(self, year: int, kind: str, *, qualified: bool = True) -> pd.DataFrame:
|
|
41
42
|
if kind not in {"batting", "pitching"}:
|
|
42
43
|
raise DataFetchError(f"Invalid kind {kind!r}.")
|
|
43
44
|
name = "Batting" if kind == "batting" else "Pitching"
|
|
@@ -75,11 +76,13 @@ class LahmanSource:
|
|
|
75
76
|
if kind == "batting":
|
|
76
77
|
totals["PA"] = totals[["AB", "BB", "HBP", "SF", "SH"]].sum(axis=1)
|
|
77
78
|
minimum_pa = int(3.1 * games + 0.5)
|
|
78
|
-
totals = totals[
|
|
79
|
+
totals["Qualified"] = totals["PA"] >= minimum_pa
|
|
79
80
|
else:
|
|
80
81
|
totals["IP"] = totals["IPouts"] / 3
|
|
81
82
|
totals["BF"] = totals["BFP"]
|
|
82
|
-
totals = totals[
|
|
83
|
+
totals["Qualified"] = totals["IPouts"] >= 3 * games
|
|
84
|
+
if qualified:
|
|
85
|
+
totals = totals[totals["Qualified"]]
|
|
83
86
|
if totals.empty:
|
|
84
87
|
raise DataFetchError(f"No qualified {kind} players in Lahman for {year}.")
|
|
85
88
|
totals = self._attach_names(totals)
|
|
@@ -101,7 +104,10 @@ class LahmanSource:
|
|
|
101
104
|
).str.strip()
|
|
102
105
|
try:
|
|
103
106
|
totals = totals.reset_index().merge(
|
|
104
|
-
people[["playerID", "Name"
|
|
107
|
+
people[["playerID", "Name", *(["birthYear"] if "birthYear" in people else [])]],
|
|
108
|
+
on="playerID",
|
|
109
|
+
how="left",
|
|
110
|
+
validate="many_to_one",
|
|
105
111
|
)
|
|
106
112
|
except pd.errors.MergeError as exc:
|
|
107
113
|
raise DataFetchError("Duplicate player IDs in Lahman People.csv.") from exc
|
|
@@ -114,25 +120,19 @@ class LahmanSource:
|
|
|
114
120
|
def project(
|
|
115
121
|
self, command: str, year: int, kind: str, name: str | None = None, team: str | None = None
|
|
116
122
|
) -> pd.DataFrame:
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
123
|
+
batting = pd.DataFrame()
|
|
124
|
+
if kind == "batting":
|
|
125
|
+
seasons, batting = self._batting_population(year)
|
|
126
|
+
else:
|
|
127
|
+
seasons = pd.concat(
|
|
128
|
+
[self.season(y, kind) for y in (year - 1, year - 2, year - 3)], ignore_index=True
|
|
129
|
+
)
|
|
120
130
|
if command == "player":
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
ids = matches["PlayerID"].unique()
|
|
126
|
-
if len(ids) == 0:
|
|
127
|
-
raise PlayerLookupError(
|
|
128
|
-
f"No qualified Lahman player found for '{name}' in the prior three seasons."
|
|
129
|
-
)
|
|
130
|
-
if len(ids) > 1:
|
|
131
|
-
raise PlayerLookupError(
|
|
132
|
-
f"Ambiguous Lahman name '{name}'; matching player IDs: {', '.join(ids)}."
|
|
131
|
+
matches = self._player_seasons(seasons, name)
|
|
132
|
+
if kind == "batting":
|
|
133
|
+
return batting[batting["PlayerID"] == matches["PlayerID"].iloc[0]].reset_index(
|
|
134
|
+
drop=True
|
|
133
135
|
)
|
|
134
|
-
if matches["Season"].nunique() != 3:
|
|
135
|
-
raise ProjectionError(f"Player '{name}' must qualify in all three prior seasons.")
|
|
136
136
|
return project_player(
|
|
137
137
|
str(matches["Name"].iloc[0]), matches, kind, year, league_df=seasons
|
|
138
138
|
)
|
|
@@ -143,4 +143,57 @@ class LahmanSource:
|
|
|
143
143
|
raise ProjectionError(
|
|
144
144
|
f"No qualified players for Lahman team ID '{team}' in the prior three seasons."
|
|
145
145
|
)
|
|
146
|
+
if kind == "batting":
|
|
147
|
+
return batting[batting["PlayerID"].isin(selected["PlayerID"])].reset_index(drop=True)
|
|
146
148
|
return project_team(selected, kind, year, baseline_df=seasons)
|
|
149
|
+
|
|
150
|
+
def _batting_population(self, year: int) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
151
|
+
self.read("People", {"playerID", "nameFirst", "nameLast", "birthYear"})
|
|
152
|
+
seasons = pd.concat(
|
|
153
|
+
[self.season(y, "batting", qualified=False) for y in (year - 1, year - 2, year - 3)],
|
|
154
|
+
ignore_index=True,
|
|
155
|
+
)
|
|
156
|
+
appearances = self.read("Appearances", {"playerID", "yearID", "G_all", "G_p"})
|
|
157
|
+
appearances = appearances[appearances["yearID"].isin([year - 1, year - 2, year - 3])].copy()
|
|
158
|
+
try:
|
|
159
|
+
appearances[["G_all", "G_p"]] = appearances[["G_all", "G_p"]].apply(
|
|
160
|
+
pd.to_numeric, errors="raise"
|
|
161
|
+
)
|
|
162
|
+
except (ValueError, TypeError) as exc:
|
|
163
|
+
raise DataFetchError("Invalid game counts in Lahman Appearances.csv.") from exc
|
|
164
|
+
counts = appearances[["G_all", "G_p"]]
|
|
165
|
+
invalid = counts.replace([float("inf"), -float("inf")], float("nan")).isna() | (counts < 0)
|
|
166
|
+
if invalid.any().any() or (appearances["G_p"] > appearances["G_all"]).any():
|
|
167
|
+
raise DataFetchError("Invalid game counts in Lahman Appearances.csv.")
|
|
168
|
+
games = appearances.groupby(["playerID", "yearID"])[["G_all", "G_p"]].sum(min_count=1)
|
|
169
|
+
seasons = seasons.merge(games, left_on=["PlayerID", "Season"], right_index=True, how="left")
|
|
170
|
+
if seasons[["G_all", "G_p"]].isna().any().any():
|
|
171
|
+
raise DataFetchError(
|
|
172
|
+
"Appearances.csv must cover every batting player-season with game counts."
|
|
173
|
+
)
|
|
174
|
+
# A primary pitcher appears as pitcher in more than half of their games.
|
|
175
|
+
# Retain two-way hitters whose batting appearances outnumber pitching games.
|
|
176
|
+
seasons = seasons[seasons["G_p"] <= seasons["G_all"] / 2].copy()
|
|
177
|
+
projected = project_batters(seasons, year)
|
|
178
|
+
qualified = seasons[seasons["Qualified"]]
|
|
179
|
+
if qualified.empty:
|
|
180
|
+
raise ProjectionError("No qualified non-pitcher batters in the prior three seasons.")
|
|
181
|
+
return qualified, projected
|
|
182
|
+
|
|
183
|
+
def _player_seasons(self, seasons: pd.DataFrame, name: str | None) -> pd.DataFrame:
|
|
184
|
+
normalized = " ".join((name or "").casefold().split())
|
|
185
|
+
matches = seasons[
|
|
186
|
+
seasons["Name"].map(lambda value: " ".join(value.casefold().split())) == normalized
|
|
187
|
+
]
|
|
188
|
+
ids = matches["PlayerID"].unique()
|
|
189
|
+
if len(ids) == 0:
|
|
190
|
+
raise PlayerLookupError(
|
|
191
|
+
f"No qualified Lahman player found for '{name}' in the prior three seasons."
|
|
192
|
+
)
|
|
193
|
+
if len(ids) > 1:
|
|
194
|
+
raise PlayerLookupError(
|
|
195
|
+
f"Ambiguous Lahman name '{name}'; matching player IDs: {', '.join(ids)}."
|
|
196
|
+
)
|
|
197
|
+
if matches["Season"].nunique() != 3:
|
|
198
|
+
raise ProjectionError(f"Player '{name}' must qualify in all three prior seasons.")
|
|
199
|
+
return matches
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: backcaster
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Marcel-style MLB projection CLI using local Lahman CSV data
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/jeffreywevans/backcaster
|
|
@@ -17,7 +17,9 @@ Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
|
17
17
|
Requires-Python: >=3.13
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
License-File: LICENSE
|
|
20
|
+
License-File: THIRD_PARTY_NOTICES.md
|
|
20
21
|
Requires-Dist: pandas>=2.0
|
|
22
|
+
Requires-Dist: numpy>=2.0
|
|
21
23
|
Provides-Extra: dev
|
|
22
24
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
23
25
|
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
@@ -54,10 +56,10 @@ Upgrade to the latest release available on PyPI:
|
|
|
54
56
|
python -m pip install --upgrade backcaster
|
|
55
57
|
```
|
|
56
58
|
|
|
57
|
-
To install a specific release, such as 0.3.
|
|
59
|
+
To install a specific release, such as 0.3.4:
|
|
58
60
|
|
|
59
61
|
```powershell
|
|
60
|
-
python -m pip install --upgrade backcaster==0.3.
|
|
62
|
+
python -m pip install --upgrade backcaster==0.3.4
|
|
61
63
|
```
|
|
62
64
|
|
|
63
65
|
Run the command using the same Python environment where you run Backcaster.
|
|
@@ -121,7 +123,8 @@ All projections use local Lahman CSVs. On Windows, the default directory is
|
|
|
121
123
|
Set the `LAHMAN_DATA_DIR` environment variable to change the default, or pass
|
|
122
124
|
`--data-dir` to override it for one command. No network requests or online
|
|
123
125
|
fallbacks are made. The directory must contain `People.csv`, `Teams.csv`, and
|
|
124
|
-
`Batting.csv` or `Pitching.csv` for the selected kind.
|
|
126
|
+
`Batting.csv` or `Pitching.csv` for the selected kind. Batter projections also
|
|
127
|
+
require `Appearances.csv` and a valid `birthYear` in `People.csv`. Filenames are matched
|
|
125
128
|
without regard to case. The database is not bundled with Backcaster.
|
|
126
129
|
|
|
127
130
|
For the default Windows directory, no additional flag is needed:
|
|
@@ -161,7 +164,10 @@ Pitching `IPouts` is divided by three for decimal innings, and `BFP` supplies
|
|
|
161
164
|
batters faced. Lahman player IDs keep different players with the same name separate;
|
|
162
165
|
ambiguous name lookups report the matching IDs.
|
|
163
166
|
|
|
164
|
-
|
|
167
|
+
Batter league regression and rebaselining use all non-pitcher player-seasons;
|
|
168
|
+
qualification selects the displayed players. Pitching retains its qualified-only
|
|
169
|
+
baseline. See [the batter model](docs/BATTER_MODEL.md) for the six-stage calculation,
|
|
170
|
+
real-age adjustment, PA formula, and explicit rebaselining choices.
|
|
165
171
|
Single-player projections require qualification in all three preceding seasons.
|
|
166
172
|
Team and batch projections use each player's available qualified seasons within
|
|
167
173
|
that window. For team selection, a traded player's full-season totals are assigned
|
|
@@ -292,6 +298,20 @@ Sonar consumes:
|
|
|
292
298
|
|
|
293
299
|
---
|
|
294
300
|
|
|
301
|
+
## Acknowledgments
|
|
302
|
+
|
|
303
|
+
Thank you to **Tom Tango** for developing and publishing the Marcel system, and
|
|
304
|
+
to **Ben Dilday** for [marcelR](https://github.com/bdilday/marcelR), whose open R
|
|
305
|
+
implementation provides a valuable methodological reference for our Python work.
|
|
306
|
+
We document the reviewed formulas, source revision, and implementation differences
|
|
307
|
+
in [our reference notes](docs/MARCELR_REFERENCE.md). The complete marcelR MIT
|
|
308
|
+
notice is preserved in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) and shipped
|
|
309
|
+
with our distributions.
|
|
310
|
+
|
|
311
|
+
Thanks also to SABR, Sean Lahman, and the database contributors. See
|
|
312
|
+
[Lahman attribution and licensing](docs/LAHMAN.md). These acknowledgments do not
|
|
313
|
+
imply endorsement or exact agreement with another implementation's projections.
|
|
314
|
+
|
|
295
315
|
## Security and responsible use
|
|
296
316
|
|
|
297
317
|
Please review `SECURITY.md` for reporting guidance. Backcaster is an analytical utility and should be validated independently before use in production pipelines.
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
LICENSE
|
|
2
2
|
README.md
|
|
3
|
+
THIRD_PARTY_NOTICES.md
|
|
3
4
|
pyproject.toml
|
|
4
5
|
src/backcaster/__init__.py
|
|
6
|
+
src/backcaster/batters.py
|
|
5
7
|
src/backcaster/cli.py
|
|
6
8
|
src/backcaster/data.py
|
|
7
9
|
src/backcaster/lahman.py
|
|
@@ -15,6 +17,7 @@ src/backcaster.egg-info/dependency_links.txt
|
|
|
15
17
|
src/backcaster.egg-info/entry_points.txt
|
|
16
18
|
src/backcaster.egg-info/requires.txt
|
|
17
19
|
src/backcaster.egg-info/top_level.txt
|
|
20
|
+
tests/test_batters.py
|
|
18
21
|
tests/test_cli.py
|
|
19
22
|
tests/test_lahman.py
|
|
20
23
|
tests/test_marcel_math.py
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from backcaster import batters
|
|
6
|
+
from backcaster.batters import EVENTS, _events, _rebaseline, project_batters
|
|
7
|
+
from backcaster.marcel import ProjectionError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def history() -> pd.DataFrame:
|
|
11
|
+
rows = []
|
|
12
|
+
for year, pa, hr in [(2024, 600, 60), (2023, 300, 30), (2022, 100, 0)]:
|
|
13
|
+
for player, exposure, homers in [("A", pa, hr), ("B", 1000 - pa, 0)]:
|
|
14
|
+
rows.append(
|
|
15
|
+
dict(
|
|
16
|
+
PlayerID=player,
|
|
17
|
+
Name=player,
|
|
18
|
+
Season=year,
|
|
19
|
+
PA=exposure,
|
|
20
|
+
AB=exposure,
|
|
21
|
+
H=exposure * 0.25,
|
|
22
|
+
SO=exposure * 0.2,
|
|
23
|
+
HR=homers,
|
|
24
|
+
**{"2B": exposure * 0.05, "3B": 0},
|
|
25
|
+
BB=0,
|
|
26
|
+
HBP=0,
|
|
27
|
+
SF=0,
|
|
28
|
+
SH=0,
|
|
29
|
+
birthYear=1996,
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
return pd.DataFrame(rows)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_exposure_weighted_league_and_playing_time(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
36
|
+
monkeypatch.setattr(batters, "_rebaseline", lambda events, pa, target: events)
|
|
37
|
+
result = project_batters(history(), 2025).set_index("PlayerID")
|
|
38
|
+
assert result.loc["A", "PA"] == 530
|
|
39
|
+
# Weighted player PA=4,500; HR=420; league HR/PA=.06/.03/0.
|
|
40
|
+
# Player-specific baseline=(3000*.06+1200*.03+300*0)/4500=.048.
|
|
41
|
+
assert result.loc["A", "HR"] == pytest.approx(530 * (420 + 1200 * 0.048) / 5700)
|
|
42
|
+
assert result.loc["A", "Reliability"] == pytest.approx(4500 / 5700)
|
|
43
|
+
assert result.loc["A", "Age"] == 29
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_rebaselining_conserves_player_pa_and_league_rates() -> None:
|
|
47
|
+
data = history()
|
|
48
|
+
result = project_batters(data, 2025)
|
|
49
|
+
target = _events(data[data.Season == 2024]).sum() / 1000
|
|
50
|
+
actual = _events(result).sum() / result.PA.sum()
|
|
51
|
+
np.testing.assert_allclose(actual, target, atol=1e-10)
|
|
52
|
+
np.testing.assert_allclose(_events(result).sum(axis=1), result.PA)
|
|
53
|
+
assert (result[EVENTS[1:-1]] >= 0).all().all()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_age_changes_skills_without_changing_pa(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
57
|
+
monkeypatch.setattr(batters, "_rebaseline", lambda events, pa, target: events)
|
|
58
|
+
data = history()
|
|
59
|
+
neutral = project_batters(data, 2025).set_index("PlayerID")
|
|
60
|
+
data.loc[data.PlayerID == "A", "birthYear"] = 1993 # Age 32.
|
|
61
|
+
data.loc[data.PlayerID == "B", "birthYear"] = 2000 # Age 25.
|
|
62
|
+
adjusted = project_batters(data, 2025).set_index("PlayerID")
|
|
63
|
+
assert adjusted.loc["A", "AVG"] < neutral.loc["A", "AVG"]
|
|
64
|
+
assert adjusted.loc["B", "AVG"] > neutral.loc["B", "AVG"]
|
|
65
|
+
pd.testing.assert_series_equal(adjusted.PA, neutral.PA)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_calendar_weights_missing_season_and_no_future_leakage(
|
|
69
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
70
|
+
) -> None:
|
|
71
|
+
monkeypatch.setattr(batters, "_rebaseline", lambda events, pa, target: events)
|
|
72
|
+
data = history()
|
|
73
|
+
data = data[~((data.PlayerID == "A") & (data.Season == 2023))]
|
|
74
|
+
future = data[data.Season == 2024].assign(Season=2025, HR=9999)
|
|
75
|
+
result = project_batters(pd.concat([data, future]), 2025).set_index("PlayerID")
|
|
76
|
+
assert result.loc["A", "PA"] == 500
|
|
77
|
+
assert result.loc["A", "Reliability"] == pytest.approx(3300 / 4500)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@pytest.mark.parametrize(
|
|
81
|
+
"case, message",
|
|
82
|
+
[
|
|
83
|
+
("empty", "No non-pitcher"),
|
|
84
|
+
("latest", "Latest completed"),
|
|
85
|
+
("counts", "finite and consistent"),
|
|
86
|
+
("birth", "birthYear"),
|
|
87
|
+
],
|
|
88
|
+
)
|
|
89
|
+
def test_bad_history(case: str, message: str) -> None:
|
|
90
|
+
data = history()
|
|
91
|
+
if case == "empty":
|
|
92
|
+
data = data.iloc[:0]
|
|
93
|
+
elif case == "latest":
|
|
94
|
+
data = data[data.Season != 2024]
|
|
95
|
+
elif case == "counts":
|
|
96
|
+
data["H"] = -1
|
|
97
|
+
else:
|
|
98
|
+
data["birthYear"] = np.nan
|
|
99
|
+
with pytest.raises(ProjectionError, match=message):
|
|
100
|
+
project_batters(data, 2025)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_zero_denominators_and_absent_events() -> None:
|
|
104
|
+
data = history()
|
|
105
|
+
data[["H", "2B", "HR", "SO"]] = 0
|
|
106
|
+
data["AB"] = 0
|
|
107
|
+
data["BB"] = data["PA"]
|
|
108
|
+
result = project_batters(data, 2025)
|
|
109
|
+
assert result.AVG.eq(0).all()
|
|
110
|
+
assert result.OBP.eq(1).all()
|
|
111
|
+
assert result.SLG.eq(0).all()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_rebaseline_nonconvergence(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
115
|
+
monkeypatch.setattr(batters.np, "allclose", lambda *args, **kwargs: False)
|
|
116
|
+
events = pd.DataFrame([{c: 1.0 for c in EVENTS}])
|
|
117
|
+
pa = pd.Series([10.0])
|
|
118
|
+
target = pd.Series({c: 0.1 for c in EVENTS})
|
|
119
|
+
with pytest.raises(ProjectionError, match="did not converge"):
|
|
120
|
+
_rebaseline(events, pa, target)
|
|
@@ -13,9 +13,9 @@ from backcaster.marcel import ProjectionError
|
|
|
13
13
|
def lahman(tmp_path: Path) -> Path:
|
|
14
14
|
pd.DataFrame(
|
|
15
15
|
[
|
|
16
|
-
{"playerID": "star", "nameFirst": "Test", "nameLast": "Star"},
|
|
17
|
-
{"playerID": "other", "nameFirst": "Other", "nameLast": "Player"},
|
|
18
|
-
{"playerID": "minor", "nameFirst": "Brief", "nameLast": "Career"},
|
|
16
|
+
{"playerID": "star", "nameFirst": "Test", "nameLast": "Star", "birthYear": 1990},
|
|
17
|
+
{"playerID": "other", "nameFirst": "Other", "nameLast": "Player", "birthYear": 1993},
|
|
18
|
+
{"playerID": "minor", "nameFirst": "Brief", "nameLast": "Career", "birthYear": 1995},
|
|
19
19
|
]
|
|
20
20
|
).to_csv(tmp_path / "People.csv", index=False)
|
|
21
21
|
batting, pitching, teams = [], [], []
|
|
@@ -29,11 +29,11 @@ def lahman(tmp_path: Path) -> Path:
|
|
|
29
29
|
stint=1,
|
|
30
30
|
teamID="BOS",
|
|
31
31
|
AB=ab,
|
|
32
|
-
H=150,
|
|
33
|
-
**{"2B": 30, "3B": 2},
|
|
34
|
-
HR=20,
|
|
32
|
+
H=150 if player != "minor" else 5,
|
|
33
|
+
**{"2B": 30 if player != "minor" else 1, "3B": 2 if player != "minor" else 0},
|
|
34
|
+
HR=20 if player != "minor" else 0,
|
|
35
35
|
BB=bb,
|
|
36
|
-
SO=100,
|
|
36
|
+
SO=100 if player != "minor" else 5,
|
|
37
37
|
HBP=5,
|
|
38
38
|
SF=5,
|
|
39
39
|
SH=1,
|
|
@@ -54,6 +54,13 @@ def lahman(tmp_path: Path) -> Path:
|
|
|
54
54
|
BFP=700,
|
|
55
55
|
)
|
|
56
56
|
)
|
|
57
|
+
pd.DataFrame(
|
|
58
|
+
[
|
|
59
|
+
dict(playerID=p, yearID=y, G_all=100, G_p=0)
|
|
60
|
+
for p in ["star", "other", "minor"]
|
|
61
|
+
for y in (2019, 2020, 2021)
|
|
62
|
+
]
|
|
63
|
+
).to_csv(tmp_path / "Appearances.csv", index=False)
|
|
57
64
|
pd.DataFrame(batting).to_csv(tmp_path / "Batting.csv", index=False)
|
|
58
65
|
pd.DataFrame(pitching).to_csv(tmp_path / "Pitching.csv", index=False)
|
|
59
66
|
pd.DataFrame(teams).to_csv(tmp_path / "Teams.csv", index=False)
|
|
@@ -90,6 +97,7 @@ def test_short_season_threshold(lahman: Path) -> None:
|
|
|
90
97
|
path = lahman / "Batting.csv"
|
|
91
98
|
frame = pd.read_csv(path)
|
|
92
99
|
frame.loc[frame.playerID == "star", ["AB", "BB", "HBP", "SF", "SH"]] = [190, 0, 0, 0, 0]
|
|
100
|
+
frame.loc[frame.playerID == "star", ["H", "SO"]] = [60, 20]
|
|
93
101
|
frame.to_csv(path, index=False)
|
|
94
102
|
source = LahmanSource(str(lahman))
|
|
95
103
|
assert "star" in set(source.season(2020, "batting").PlayerID)
|
|
@@ -221,7 +229,7 @@ def test_cli_missing_directory_returns_error(
|
|
|
221
229
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
222
230
|
) -> None:
|
|
223
231
|
assert main(["batch", "--year", "2022", "--kind", "batting", "--data-dir", str(tmp_path)]) == 2
|
|
224
|
-
assert "Expected
|
|
232
|
+
assert "Expected People.csv" in capsys.readouterr().err
|
|
225
233
|
|
|
226
234
|
|
|
227
235
|
def test_qualification_boundary(lahman: Path) -> None:
|
|
@@ -261,4 +269,71 @@ def test_missing_default_files_never_fall_back_to_network(
|
|
|
261
269
|
monkeypatch.setattr("socket.socket.connect", forbidden)
|
|
262
270
|
monkeypatch.setenv("LAHMAN_DATA_DIR", str(tmp_path))
|
|
263
271
|
assert main(["player", "--name", "Test Star", "--year", "2022", "--kind", "batting"]) == 2
|
|
264
|
-
assert "Expected
|
|
272
|
+
assert "Expected People.csv" in capsys.readouterr().err
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_batter_commands_share_population_rebaseline(lahman: Path) -> None:
|
|
276
|
+
source = LahmanSource(str(lahman))
|
|
277
|
+
player = source.project("player", 2022, "batting", name="Test Star")
|
|
278
|
+
batch = source.project("batch", 2022, "batting")
|
|
279
|
+
team = source.project("team", 2022, "batting", team="BOS")
|
|
280
|
+
expected = batch[batch.PlayerID == "star"].reset_index(drop=True)
|
|
281
|
+
pd.testing.assert_frame_equal(player, expected)
|
|
282
|
+
pd.testing.assert_frame_equal(team, batch)
|
|
283
|
+
assert player.loc[0, "PA"] == pytest.approx(0.5 * 571 + 0.1 * 571 + 200)
|
|
284
|
+
assert player.loc[0, "Age"] == 32
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def test_pitcher_exclusion_and_unqualified_league_context(lahman: Path) -> None:
|
|
288
|
+
source = LahmanSource(str(lahman))
|
|
289
|
+
before = source.project("batch", 2022, "batting")
|
|
290
|
+
path = lahman / "Appearances.csv"
|
|
291
|
+
appearances = pd.read_csv(path)
|
|
292
|
+
appearances.loc[appearances.playerID == "minor", "G_p"] = 100
|
|
293
|
+
appearances.to_csv(path, index=False)
|
|
294
|
+
after = LahmanSource(str(lahman)).project("batch", 2022, "batting")
|
|
295
|
+
assert set(after.PlayerID) == {"star", "other"}
|
|
296
|
+
assert not before.HR.equals(after.HR)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@pytest.mark.parametrize(
|
|
300
|
+
"case, message",
|
|
301
|
+
[
|
|
302
|
+
("missing", "must cover"),
|
|
303
|
+
("numeric", "Invalid game counts"),
|
|
304
|
+
("no_qualified", "No qualified non-pitcher"),
|
|
305
|
+
],
|
|
306
|
+
)
|
|
307
|
+
def test_batting_population_errors(lahman: Path, case: str, message: str) -> None:
|
|
308
|
+
path = lahman / "Appearances.csv"
|
|
309
|
+
frame = pd.read_csv(path)
|
|
310
|
+
if case == "missing":
|
|
311
|
+
frame = frame[frame.playerID != "star"]
|
|
312
|
+
elif case == "numeric":
|
|
313
|
+
frame["G_p"] = "invalid"
|
|
314
|
+
else:
|
|
315
|
+
# Only the unqualified player remains a non-pitcher.
|
|
316
|
+
frame.loc[frame.playerID != "minor", "G_p"] = 100
|
|
317
|
+
frame.to_csv(path, index=False)
|
|
318
|
+
source = LahmanSource(str(lahman))
|
|
319
|
+
with pytest.raises((DataFetchError, ProjectionError), match=message):
|
|
320
|
+
source.project("batch", 2022, "batting")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@pytest.mark.parametrize("bad", [-1, 101, float("inf"), float("nan")])
|
|
324
|
+
def test_invalid_appearance_counts(lahman: Path, bad: float) -> None:
|
|
325
|
+
path = lahman / "Appearances.csv"
|
|
326
|
+
frame = pd.read_csv(path)
|
|
327
|
+
frame["G_p"] = bad
|
|
328
|
+
frame.to_csv(path, index=False)
|
|
329
|
+
source = LahmanSource(str(lahman))
|
|
330
|
+
with pytest.raises(DataFetchError, match="Invalid game counts"):
|
|
331
|
+
source.project("batch", 2022, "batting")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_missing_birth_year_column(lahman: Path) -> None:
|
|
335
|
+
path = lahman / "People.csv"
|
|
336
|
+
pd.read_csv(path).drop(columns="birthYear").to_csv(path, index=False)
|
|
337
|
+
source = LahmanSource(str(lahman))
|
|
338
|
+
with pytest.raises(DataFetchError, match="birthYear"):
|
|
339
|
+
source.project("batch", 2022, "batting")
|
|
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
|