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.
Files changed (27) hide show
  1. {backcaster-0.3.3/src/backcaster.egg-info → backcaster-0.3.4}/PKG-INFO +25 -5
  2. {backcaster-0.3.3 → backcaster-0.3.4}/README.md +22 -4
  3. backcaster-0.3.4/THIRD_PARTY_NOTICES.md +52 -0
  4. {backcaster-0.3.3 → backcaster-0.3.4}/pyproject.toml +2 -1
  5. {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster/__init__.py +1 -1
  6. backcaster-0.3.4/src/backcaster/batters.py +118 -0
  7. {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster/lahman.py +74 -21
  8. {backcaster-0.3.3 → backcaster-0.3.4/src/backcaster.egg-info}/PKG-INFO +25 -5
  9. {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster.egg-info/SOURCES.txt +3 -0
  10. {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster.egg-info/requires.txt +1 -0
  11. backcaster-0.3.4/tests/test_batters.py +120 -0
  12. {backcaster-0.3.3 → backcaster-0.3.4}/tests/test_lahman.py +84 -9
  13. {backcaster-0.3.3 → backcaster-0.3.4}/LICENSE +0 -0
  14. {backcaster-0.3.3 → backcaster-0.3.4}/setup.cfg +0 -0
  15. {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster/cli.py +0 -0
  16. {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster/data.py +0 -0
  17. {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster/marcel.py +0 -0
  18. {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster/normalize.py +0 -0
  19. {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster/outputs.py +0 -0
  20. {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster/schemas.py +0 -0
  21. {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster.egg-info/dependency_links.txt +0 -0
  22. {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster.egg-info/entry_points.txt +0 -0
  23. {backcaster-0.3.3 → backcaster-0.3.4}/src/backcaster.egg-info/top_level.txt +0 -0
  24. {backcaster-0.3.3 → backcaster-0.3.4}/tests/test_cli.py +0 -0
  25. {backcaster-0.3.3 → backcaster-0.3.4}/tests/test_marcel_math.py +0 -0
  26. {backcaster-0.3.3 → backcaster-0.3.4}/tests/test_normalize.py +0 -0
  27. {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
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.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.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. Filenames are matched
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
- Only qualified player-seasons enter projections and league regression baselines.
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.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.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. Filenames are matched
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
- Only qualified player-seasons enter projections and league regression baselines.
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]
@@ -1,4 +1,4 @@
1
1
  """backcaster package."""
2
2
 
3
3
  __all__ = ["__version__"]
4
- __version__ = "0.3.3"
4
+ __version__ = "0.3.4"
@@ -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[totals["PA"] >= minimum_pa]
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[totals["IPouts"] >= 3 * games]
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"]], on="playerID", how="left", validate="many_to_one"
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
- seasons = pd.concat(
118
- [self.season(y, kind) for y in (year - 1, year - 2, year - 3)], ignore_index=True
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
- normalized = " ".join((name or "").casefold().split())
122
- matches = seasons[
123
- seasons["Name"].map(lambda value: " ".join(value.casefold().split())) == normalized
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
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.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.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. Filenames are matched
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
- Only qualified player-seasons enter projections and league regression baselines.
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
@@ -1,4 +1,5 @@
1
1
  pandas>=2.0
2
+ numpy>=2.0
2
3
 
3
4
  [dev]
4
5
  pytest>=8.0
@@ -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 Batting.csv" in capsys.readouterr().err
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 Batting.csv" in capsys.readouterr().err
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