diff --git a/models/gbm.joblib b/models/gbm.joblib new file mode 100644 index 0000000..3f2100a Binary files /dev/null and b/models/gbm.joblib differ diff --git a/models/gbm.json b/models/gbm.json new file mode 100644 index 0000000..ef10444 --- /dev/null +++ b/models/gbm.json @@ -0,0 +1,27 @@ +{ + "n_train": 7769, + "base_rate": 0.098, + "cv": { + "ap": 0.278, + "auc": 0.753 + }, + "trained_at": "2026-11-13T18:19:35.186273+00:00", + "sklearn": "1.9.1", + "features": [ + "gfi", + "help_wanted", + "bug", + "docs", + "enhancement", + "n_labels", + "body_len_log", + "title_len", + "has_code", + "is_maintainer", + "stars_log", + "forks_log", + "repo_size_log", + "has_license", + "repo_age_days" + ] +} \ No newline at end of file diff --git a/src/lysis/app.py b/src/lysis/app.py index d2c724e..09cc93b 100644 --- a/src/lysis/app.py +++ b/src/lysis/app.py @@ -6,8 +6,7 @@ from lysis.cli import rank_repo from lysis.ingestion.github_client import GitHubClient -from lysis.models.evaluate import load_dataset -from lysis.models.gbm import make_gbm_ranker +from lysis.models.artifact import load_meta, load_model load_dotenv() st.set_page_config(page_title="Lysis", page_icon="🧭") @@ -15,14 +14,16 @@ @st.cache_resource def get_model(): - df = load_dataset() - return make_gbm_ranker(df), len(df), df["first_timer"].mean() + return load_model(), load_meta() -ranker, n, base = get_model() +ranker, meta = get_model() st.title("Lysis") st.caption("Beginner-friendly GitHub issue ranker") -st.sidebar.write(f"Model trained on {n:,} closed issues. Base rate: {base:.2f}") +st.sidebar.write( + f"Model trained {meta['trained_at'][:10]} on {meta['n_train']:,} issues. " + f"CV AP {meta['cv']['ap']}, AUC {meta['cv']['auc']}" +) repo = st.text_input("Repository", "psf/requests", placeholder="owner/repo") top = st.slider("Top N", 5, 30, 10) diff --git a/src/lysis/cli.py b/src/lysis/cli.py index 16b679f..4efd8e8 100644 --- a/src/lysis/cli.py +++ b/src/lysis/cli.py @@ -7,8 +7,7 @@ from lysis.features.rows import issue_row from lysis.ingestion.github_client import GitHubClient -from lysis.models.evaluate import load_dataset -from lysis.models.gbm import make_gbm_ranker +from lysis.models.artifact import load_model def rank_repo(gh, full_name, ranker, top=10, half_life_days=365): @@ -33,7 +32,7 @@ def main(): if len(sys.argv) < 2: sys.exit("usage: python src\\lysis\\cli.py owner/repo [top]") top = int(sys.argv[2]) if len(sys.argv) > 2 else 10 - ranker = make_gbm_ranker(load_dataset()) + ranker = load_model() print(rank_repo(GitHubClient(), sys.argv[1], ranker, top).to_string(index=False)) diff --git a/src/lysis/models/artifact.py b/src/lysis/models/artifact.py new file mode 100644 index 0000000..144283a --- /dev/null +++ b/src/lysis/models/artifact.py @@ -0,0 +1,32 @@ +"""Save and load the trained ranker as a versioned artifact.""" + +import json +from datetime import UTC, datetime +from pathlib import Path + +import joblib +import sklearn + +MODEL_PATH = Path("models/gbm.joblib") + + +def save_model(ranker, meta, path=MODEL_PATH): + path.parent.mkdir(parents=True, exist_ok=True) + joblib.dump(ranker, path) + meta = { + **meta, + "trained_at": datetime.now(UTC).isoformat(), + "sklearn": sklearn.__version__, + "features": ranker.cols, + } + path.with_suffix(".json").write_text(json.dumps(meta, indent=2)) + + +def load_model(path=MODEL_PATH): + if not path.exists(): + raise FileNotFoundError("No model found. Run: python src\\lysis\\models\\train.py") + return joblib.load(path) + + +def load_meta(path=MODEL_PATH): + return json.loads(path.with_suffix(".json").read_text()) \ No newline at end of file diff --git a/src/lysis/models/gbm.py b/src/lysis/models/gbm.py index bca2669..49cde45 100644 --- a/src/lysis/models/gbm.py +++ b/src/lysis/models/gbm.py @@ -5,12 +5,17 @@ from lysis.features.issue_features import FEATURE_COLUMNS, build_features +class GBMRanker: + def __init__(self, model, cols): + self.model = model + self.cols = cols + + def __call__(self, df): + return self.model.predict_proba(build_features(df)[self.cols])[:, 1] + + def make_gbm_ranker(train, seed=42, drop=()): cols = [c for c in FEATURE_COLUMNS if c not in drop] model = HistGradientBoostingClassifier(max_depth=3, learning_rate=0.1, random_state=seed) model.fit(build_features(train)[cols], train["first_timer"]) - - def ranker(df): - return model.predict_proba(build_features(df)[cols])[:, 1] - - return ranker \ No newline at end of file + return GBMRanker(model, cols) \ No newline at end of file diff --git a/src/lysis/models/train.py b/src/lysis/models/train.py new file mode 100644 index 0000000..b8114c6 --- /dev/null +++ b/src/lysis/models/train.py @@ -0,0 +1,19 @@ +"""Train on all data, report CV, save the artifact.""" + +from lysis.models.artifact import save_model +from lysis.models.evaluate import cross_validate, load_dataset +from lysis.models.gbm import make_gbm_ranker + + +def main(): + df = load_dataset() + cv = cross_validate(df, {"gbm": make_gbm_ranker})["gbm"] + save_model( + make_gbm_ranker(df), + {"n_train": len(df), "base_rate": round(float(df["first_timer"].mean()), 3), "cv": cv}, + ) + print("saved models/gbm.joblib", cv) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_artifact.py b/tests/test_artifact.py new file mode 100644 index 0000000..32b117c --- /dev/null +++ b/tests/test_artifact.py @@ -0,0 +1,13 @@ +import pandas as pd + +from lysis.models.artifact import load_meta, load_model, save_model +from lysis.models.gbm import make_gbm_ranker + + +def test_save_load_roundtrip(issues_df, tmp_path): + big = pd.concat([issues_df] * 10, ignore_index=True) + ranker = make_gbm_ranker(big) + path = tmp_path / "m.joblib" + save_model(ranker, {"n_train": len(big)}, path) + assert list(load_model(path)(big)) == list(ranker(big)) + assert load_meta(path)["n_train"] == len(big) \ No newline at end of file