Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added models/gbm.joblib
Binary file not shown.
27 changes: 27 additions & 0 deletions models/gbm.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
13 changes: 7 additions & 6 deletions src/lysis/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,24 @@

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="🧭")


@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)

Expand Down
5 changes: 2 additions & 3 deletions src/lysis/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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))


Expand Down
32 changes: 32 additions & 0 deletions src/lysis/models/artifact.py
Original file line number Diff line number Diff line change
@@ -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())
15 changes: 10 additions & 5 deletions src/lysis/models/gbm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
return GBMRanker(model, cols)
19 changes: 19 additions & 0 deletions src/lysis/models/train.py
Original file line number Diff line number Diff line change
@@ -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()
13 changes: 13 additions & 0 deletions tests/test_artifact.py
Original file line number Diff line number Diff line change
@@ -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)
Loading