DEV Community

YMori
YMori

Posted on • Edited on • Originally published at zenn.dev

Annual Auto-Retraining for NPB Baseball Predictions with GitHub Actions

Background

I built a Japanese professional baseball (NPB) player performance prediction system.

→ Previous article: Why Marcel Beat LightGBM: Building an NPB Player Performance Prediction System

After getting it working, I realized I was running python ml_projection.py manually every March (before the season starts).

That meant all of this was manual:

  • Web scraping (data fetch)
  • Model retraining
  • Prediction CSV update
  • Checking whether accuracy improved year-over-year

So I automated it with GitHub Actions, and added model artifact saving and accuracy logging.

GitHub: https://github.com/yasumorishima/npb-prediction

3 Things I Added

Feature What I did
Model saving joblib.pkl files in data/models/, one per year
Metrics logging Marcel vs ML MAE saved to JSON + FastAPI /metrics endpoint
Auto-run GitHub Actions cron, every March 1st (after FA/transfers finalized)

① Save Models with joblib

After training, save each model to a .pkl file:

import joblib
from pathlib import Path

MODELS_DIR = Path("data/models")
MODELS_DIR.mkdir(parents=True, exist_ok=True)

for model_name, res in h_results.items():
    if "model" in res:
        pkl_path = MODELS_DIR / f"{model_name}_hitters_{TARGET_YEAR}.pkl"
        joblib.dump(res["model"], pkl_path)
        print(f"Saved: {pkl_path}")
Enter fullscreen mode Exit fullscreen mode

The year in the filename prevents overwriting:

data/models/
├── lgb_hitters_2026.pkl
├── xgb_hitters_2026.pkl
├── lgb_pitchers_2026.pkl
└── xgb_pitchers_2026.pkl
Enter fullscreen mode Exit fullscreen mode

② Log Accuracy to JSON, Expose via FastAPI

Save to JSON

import json
from datetime import datetime

metrics = {
    "year": TARGET_YEAR,
    "data_end_year": DATA_END_YEAR,
    "generated_at": datetime.utcnow().isoformat(),
    "hitter": {k: round(v["mae"], 4) for k, v in h_results.items() if "mae" in v},
    "pitcher": {k: round(v["mae"], 4) for k, v in p_results.items() if "mae" in v},
}
metrics["hitter"]["marcel"] = round(marcel_mae, 4)
metrics["pitcher"]["marcel"] = round(marcel_mae_p, 4)

path = Path("data/metrics") / f"metrics_{TARGET_YEAR}.json"
with open(path, "w") as f:
    json.dump(metrics, f, indent=2)
Enter fullscreen mode Exit fullscreen mode

Example output (metrics_2026.json):

{
  "year": 2026,
  "data_end_year": 2025,
  "generated_at": "2026-11-01T09:30:00",
  "hitter": {
    "lgb": 0.031,
    "xgb": 0.033,
    "ensemble": 0.030,
    "marcel": 0.048
  },
  "pitcher": {
    "lgb": 0.58,
    "xgb": 0.61,
    "ensemble": 0.57,
    "marcel": 0.63
  }
}
Enter fullscreen mode Exit fullscreen mode

If hitter.lgb < hitter.marcel, ML is beating Marcel. Otherwise Marcel wins.

FastAPI /metrics endpoint

Reads all JSON files from data/metrics/ and returns them sorted by year:

def _load_all_metrics() -> list[dict]:
    if not METRICS_DIR.exists():
        return []
    result = []
    for p in sorted(METRICS_DIR.glob("metrics_*.json")):
        with open(p, encoding="utf-8") as f:
            result.append(json.load(f))
    return sorted(result, key=lambda x: x.get("year", 0))

all_metrics = _load_all_metrics()

@app.get("/metrics")
def get_metrics():
    if not all_metrics:
        raise HTTPException(503, "No metrics data available")
    return {"count": len(all_metrics), "metrics": all_metrics}
Enter fullscreen mode Exit fullscreen mode

As years accumulate, you can chart accuracy trends.

③ Automate Everything with GitHub Actions

Full annual_update.yml:

name: Annual NPB Update

on:
  schedule:
    - cron: '0 9 1 3 *'   # March 1st, 9:00 UTC (after FA/transfers finalized, before opening day)
  workflow_dispatch:
    inputs:
      data_end_year:
        description: 'Last season year (e.g. 2025)'
        default: ''

permissions:
  contents: write  # required for git push

jobs:
  update:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Install dependencies
        run: pip install pandas numpy beautifulsoup4 requests lxml scikit-learn lightgbm xgboost joblib

      - name: 1. Fetch hitter/pitcher stats
        run: python fetch_npb_data.py

      - name: 2. Fetch detailed batting stats
        run: python fetch_npb_detailed.py

      - name: 3. Fetch standings + Pythagorean
        run: python pythagorean.py

      - name: 4. Calculate wOBA/wRC+
        run: python sabermetrics.py

      - name: 5. Marcel projections
        run: python marcel_projection.py

      - name: 6. ML projections (LightGBM/XGBoost)
        run: python ml_projection.py

      - name: Commit and push updated data
        run: |
          git config user.name 'github-actions[bot]'
          git config user.email 'github-actions[bot]@users.noreply.github.com'
          git add data/
          if git diff --staged --quiet; then
            echo "No data changes to commit"
          else
            git commit -m "auto: update NPB data to ${NPB_DATA_END_YEAR}"
            git push
          fi
Enter fullscreen mode Exit fullscreen mode

git add data/ picks up data/models/*.pkl and data/metrics/*.json automatically.

4 Bugs That Only Appeared in CI

Local code that worked fine broke in 4 consecutive ways once it hit CI.

Bug 1: StringDtype passed to numeric operations

TypeError: can't multiply sequence by non-int of type 'str'
Enter fullscreen mode Exit fullscreen mode

Scraped data columns like AVG, OBP were still strings when used in arithmetic.

# Before: only converting RC27 and XR27
for col in ["RC27", "XR27"]:
    df[col] = pd.to_numeric(df[col], errors="coerce")

# After: convert every column you'll use
for col in ["AVG", "OBP", "SLG", "OPS", "PA", "HR", ..., "RC27", "XR27"]:
    df[col] = pd.to_numeric(df[col], errors="coerce")
Enter fullscreen mode Exit fullscreen mode

Bug 2: NaN not caught by == 0

ValueError: cannot convert float NaN to integer
Enter fullscreen mode Exit fullscreen mode

Bug 1's fix introduced NaN values, but if pa == 0: doesn't skip NaN.

float('nan') == 0  # → False (not skipped!)
Enter fullscreen mode Exit fullscreen mode
# Before
if pa == 0:
    continue

# After
if pd.isna(pa) or pa == 0:
    continue
Enter fullscreen mode Exit fullscreen mode

Bug 3: Empty test set crashes predict

ValueError: Input data must be 2 dimensional and non empty.
Enter fullscreen mode Exit fullscreen mode

Cascading NaN caused the holdout test set to be 0 rows.

# Guard against empty test set
if len(X_test) > 0:
    pred = model.predict(X_test)
    mae = mean_absolute_error(y_test, pred)
    results[name] = {"model": model, "pred": pred, "mae": mae}
else:
    print("WARNING: empty test set. Saving model only.")
    results[name] = {"model": model}  # no MAE, model saved anyway
Enter fullscreen mode Exit fullscreen mode

Bug 4: github-actions[bot] denied write access

remote: Permission to ... denied to github-actions[bot].
fatal: unable to access ...: The requested URL returned error: 403
Enter fullscreen mode Exit fullscreen mode

The default GITHUB_TOKEN is read-only. You need to declare write permission explicitly.

# At the workflow level (not inside jobs)
permissions:
  contents: write
Enter fullscreen mode Exit fullscreen mode

All 4 bugs were "worked locally" patterns. CI surfaces data quality issues you never notice running manually.

Summary

File Change
ml_projection.py joblib model save + metrics_*.json output
api.py /metrics endpoint added
requirements.txt joblib>=1.3 added
.github/workflows/annual_update.yml 8-step pipeline, runs every March 1st
fetch_rosters.py Fetch registered player roster (excludes departed/MLB players from Marcel)

Each run produces:

data/models/lgb_hitters_2026.pkl
data/models/xgb_hitters_2026.pkl
data/models/lgb_pitchers_2026.pkl
data/models/xgb_pitchers_2026.pkl
data/metrics/metrics_2026.json
Enter fullscreen mode Exit fullscreen mode

All committed to the repo automatically. Once multiple years accumulate, accuracy trends become trackable. Whether this qualifies as "MLOps" is debatable, but it's no longer a "run the script manually every March" operation.

GitHub: https://github.com/yasumorishima/npb-prediction

Top comments (0)