Ever deployed a model that suddenly started predicting wildly after a new repository appeared on GitHub? I hit that wall when a nightly batch job began crashing on a repo with a missing language field, and my validation scores were mysteriously perfect. If you’ve ever stitched together ad‑hoc pandas scripts and later discovered that scaling or imputation silently peered into the test fold, this post is for you. I’ll show you, step by step, how I turned a handful of raw JSON responses into a fully‑composable, leak‑free sklearn.pipeline.Pipeline that predicts fork counts, handles out‑of‑vocabulary languages, and stores the feature matrix on disk with np.memmap for production‑scale workloads.
Key Takeaways
- Custom
BaseEstimator/TransformerMixinclasses let you pull nested JSON fields directly into a pipeline without intermediate pandas objects. - Encoding unseen categorical values with an explicit “Other” bucket prevents runtime crashes and keeps feature dimensions stable.
- Using
np.memmapto persist the feature matrix enables pipelines to operate on datasets that exceed RAM. - Embedding the entire preprocessing stack inside
cross_validateguarantees that scaling and imputation never see validation data.
The Problem
In our analytics platform we needed to predict how many forks a newly‑created GitHub repository would attract, based solely on its telemetry (stars, issues, size, language, description length). The initial notebook mixed requests, pandas, and manual scaling, which worked on a static snapshot but leaked data during cross‑validation and blew up when the live API returned null for language. The goal was to replace that brittle script with a reusable, versioned pipeline that could be retrained nightly without human intervention.
Data and Sources
We pull live repository metadata from the GitHub REST API. The endpoint documentation is at GitHub Docs – Get a Repository. The scikit‑learn APIs referenced are Pipeline, ColumnTransformer, and the custom transformer guide Developers – Custom Estimators. Data accessed on 2026-10-28.
Loading the Data
We fetch a small but representative set of repositories. The helper handles HTTP errors and malformed JSON, returning a list of dictionaries ready for the pipeline.
import requests
import json
def load_data():
repos = [
"python/cpython",
"pallets/flask",
"psf/requests"
]
payloads = []
for repo in repos:
url = f"https://api.github.com/repos/{repo}"
try:
resp = requests.get(url, timeout=5)
resp.raise_for_status()
payloads.append(resp.json())
except requests.exceptions.HTTPError as e:
print(f"HTTP error for {repo}: {e}")
except requests.exceptions.RequestException as e:
print(f"Network error for {repo}: {e}")
except json.JSONDecodeError as e:
print(f"Invalid JSON from {repo}: {e}")
return payloads
Step 1 – Extracting Features with a Stateless Transformer
The GitHubFeatureExtractor pulls numeric fields and computes a derived ratio (issues‑to‑stars). It returns a tuple (numeric_array, language_list) so the downstream ColumnTransformer can treat them separately.
import numpy as np
from sklearn.base import BaseEstimator, TransformerMixin
class GitHubFeatureExtractor(BaseEstimator, TransformerMixin):
"""Extract numeric and categorical features from raw GitHub JSON."""
def fit(self, X, y=None):
return self
def transform(self, X):
# X is a list of dicts
numeric = []
languages = []
for rec in X:
stars = rec.get("stargazers_count", 0)
forks = rec.get("forks_count", 0)
issues = rec.get("open_issues_count", 0)
size = rec.get("size", 0)
# derived feature: issues per 100 stars
issue_ratio = issues / (stars + 1) * 100
numeric.append([stars, forks, issues, size, issue_ratio])
languages.append(rec.get("language")) # may be None
numeric_arr = np.asarray(numeric, dtype=float)
return {"numeric": numeric_arr, "language": np.asarray(languages, dtype=object)}
Step 2 – Safe Categorical Encoding
Production APIs constantly introduce new languages. SafeCategoricalEncoder learns the set of categories during fit and maps any unknown or None values to the literal string "Other". Internally it re‑uses OneHotEncoder with handle_unknown='ignore' to keep the implementation lean.
from sklearn.preprocessing import OneHotEncoder
class SafeCategoricalEncoder(BaseEstimator, TransformerMixin):
def __init__(self):
self.encoder = OneHotEncoder(handle_unknown='ignore', sparse=False)
def fit(self, X, y=None):
# X is a 1‑D array of language strings (may contain None)
cleaned = np.where(pd.isna(X), "Other", X)
self.encoder.fit(cleaned.reshape(-1, 1))
self.categories_ = set(self.encoder.categories_[0])
return self
def transform(self, X):
cleaned = np.where(pd.isna(X), "Other", X)
return self.encoder.transform(cleaned.reshape(-1, 1))
Step 3 – Building the Full Pipeline
We combine the extractor with a ColumnTransformer that routes numeric and categorical streams to their own sub‑pipelines. The numeric branch imputes missing values and standardizes them; the categorical branch uses our safe encoder.
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from sklearn.ensemble import HistGradientBoostingRegressor
numeric_pipe = Pipeline([
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler())
])
categorical_pipe = Pipeline([
("encoder", SafeCategoricalEncoder())
])
preprocess = ColumnTransformer([
("num", numeric_pipe, "numeric"),
("cat", categorical_pipe, "language")
])
model = HistGradientBoostingRegressor(max_depth=5, random_state=42)
pipeline = Pipeline([
("extract", GitHubFeatureExtractor()),
("preprocess", preprocess),
("model", model)
])
Step 4 – Memory‑Mapping the Feature Matrix
Before cross‑validation we persist the numeric matrix to disk using np.memmap. This pattern scales to millions of rows without blowing RAM, and the pipeline can read the mem‑mapped file directly if needed.
def persist_features(payloads, path="features.memmap"):
extractor = GitHubFeatureExtractor()
feats = extractor.transform(payloads)["numeric"]
# Create a writable memmap
fp = np.memmap(path, dtype='float64', mode='w+', shape=feats.shape)
fp[:] = feats[:]
fp.flush()
return path
Step 5 – Leak‑Free Cross‑Validation
We feed the raw payload list into cross_validate. Because the pipeline includes the extractor, every fold recomputes the feature matrix from scratch, guaranteeing no leakage.
def evaluate(payloads):
mae_scorer = make_scorer(mean_absolute_error, greater_is_better=False)
cv = KFold(n_splits=3, shuffle=True, random_state=42)
results = cross_validate(
pipeline,
X=payloads,
y=[rec["forks_count"] for rec in payloads],
cv=cv,
scoring=mae_scorer,
return_estimator=True
)
mean_mae = -results["test_score"].mean()
print(f"Cross‑validated MAE: {mean_mae:.2f}")
return results
Putting It Together
The main() function wires everything: data loading, feature persistence, evaluation, final training, artifact serialization, and a quick feature‑importance plot.
import joblib
import matplotlib.pyplot as plt
def main():
payloads = load_data()
if not payloads:
print("No data fetched – aborting.")
return
# Persist numeric matrix (demonstrates memmap usage)
persist_features(payloads)
# Evaluate with leak‑free CV
cv_results = evaluate(payloads)
# Fit on the full dataset
pipeline.fit(payloads, [rec["forks_count"] for rec in payloads])
# Save the trained pipeline
joblib.dump(pipeline, "github_fork_predictor.joblib")
print("Pipeline serialized to github_fork_predictor.joblib")
# Plot feature importances from the underlying regressor
importances = pipeline.named_steps["model"].feature_importances_
feature_names = (
["stars", "forks", "issues", "size", "issue_ratio"] +
list(pipeline.named_steps["preprocess"]
.named_transformers_["cat"]
.named_steps["encoder"]
.encoder.get_feature_names_out())
)
plt.figure(figsize=(8, 4))
plt.barh(feature_names, importances)
plt.xlabel("Importance")
plt.title("Feature Importances")
plt.tight_layout()
plt.savefig("feature_importance.png")
print("Feature importance chart saved as feature_importance.png")
if __name__ == "__main__":
main()
Complete Script
The full runnable script combining all steps:
#!/usr/bin/env python3
"""
Leak‑free GitHub fork‑count predictor using custom scikit‑learn transformers
and memory‑mapped feature storage.
"""
import json
import os
import sys
import requests
import numpy as np
import pandas as pd
import joblib
import matplotlib.pyplot as plt
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.model_selection import cross_validate, KFold
from sklearn.metrics import mean_absolute_error, make_scorer
# ----------------------------------------------------------------------
# Step 1 – Data loader
# ----------------------------------------------------------------------
def load_data():
repos = [
"python/cpython",
"pallets/flask",
"psf/requests"
]
payloads = []
for repo in repos:
url = f"https://api.github.com/repos/{repo}"
try:
resp = requests.get(url, timeout=5)
resp.raise_for_status()
payloads.append(resp.json())
except requests.exceptions.HTTPError as e:
print(f"[HTTP] {repo}: {e}")
except requests.exceptions.RequestException as e:
print(f"[Network] {repo}: {e}")
except json.JSONDecodeError as e:
print(f"[JSON] {repo}: {e}")
return payloads
# ----------------------------------------------------------------------
# Step 2 – Custom feature extractor (stateless)
# ----------------------------------------------------------------------
class GitHubFeatureExtractor(BaseEstimator, TransformerMixin):
"""Extract numeric and language fields from raw GitHub JSON."""
def fit(self, X, y=None):
return self
def transform(self, X):
numeric = []
languages = []
for rec in X:
stars = rec.get("stargazers_count", 0)
forks = rec.get("forks_count", 0)
issues = rec.get("open_issues_count", 0)
size = rec.get("size", 0)
issue_ratio = issues / (stars + 1) * 100
numeric.append([stars, forks, issues, size, issue_ratio])
languages.append(rec.get("language")) # may be None
numeric_arr = np.asarray(numeric, dtype=float)
return {"numeric": numeric_arr, "language": np.asarray(languages, dtype=object)}
# ----------------------------------------------------------------------
# Step 3 – Safe categorical encoder (stateful)
# ----------------------------------------------------------------------
class SafeCategoricalEncoder(BaseEstimator, TransformerMixin):
"""Map unknown or missing categories to an explicit 'Other' bucket."""
def __init__(self):
self.encoder = OneHotEncoder(handle_unknown='ignore', sparse=False)
def fit(self, X, y=None):
cleaned = np.where(pd.isna(X), "Other", X)
self.encoder.fit(cleaned.reshape(-1, 1))
self.categories_ = set(self.encoder.categories_[0])
return self
def transform(self, X):
cleaned = np.where(pd.isna(X), "Other", X)
return self.encoder.transform(cleaned.reshape(-1, 1))
# ----------------------------------------------------------------------
# Step 4 – Build the full pipeline
# ----------------------------------------------------------------------
numeric_pipe = Pipeline([
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler())
])
categorical_pipe = Pipeline([
("encoder", SafeCategoricalEncoder())
])
preprocess = ColumnTransformer([
("num", numeric_pipe, "numeric"),
("cat", categorical_pipe, "language")
])
model = HistGradientBoostingRegressor(max_depth=5, random_state=42)
pipeline = Pipeline([
("extract", GitHubFeatureExtractor()),
("preprocess", preprocess),
("model", model)
])
# ----------------------------------------------------------------------
# Step 5 – Persist numeric matrix with memmap (demonstrates scaling)
# ----------------------------------------------------------------------
def persist_features(payloads, path="features.memmap"):
extractor = GitHubFeatureExtractor()
feats = extractor.transform(payloads)["numeric"]
fp = np.memmap(path, dtype='float64', mode='w+', shape=feats.shape)
fp[:] = feats[:]
fp.flush()
return path
# ----------------------------------------------------------------------
# Step 6 – Leak‑free cross‑validation
# ----------------------------------------------------------------------
def evaluate(payloads):
mae_scorer = make_scorer(mean_absolute_error, greater_is_better=False)
cv = KFold(n_splits=3, shuffle=True, random_state=42)
results = cross_validate(
pipeline,
X=payloads,
y=[rec["forks_count"] for rec in payloads],
cv=cv,
scoring=mae_scorer,
return_estimator=True
)
mean_mae = -results["test_score"].mean()
print(f"Cross‑validated MAE: {mean_mae:.2f}")
return results
# ----------------------------------------------------------------------
# Step 7 – Main orchestration
# ----------------------------------------------------------------------
def main():
payloads = load_data()
if not payloads:
print("No repositories fetched – exiting.")
return
# Demonstrate memmap usage
persist_features(payloads)
# Evaluate safely
evaluate(payloads)
# Fit on the full data
pipeline.fit(payloads, [rec["forks_count"] for rec in payloads])
# Serialize the pipeline for production
joblib.dump(pipeline, "github_fork_predictor.joblib")
print("Serialized pipeline to github_fork_predictor.joblib")
# Plot importances
importances = pipeline.named_steps["model"].feature_importances_
feature_names = (
["stars", "forks", "issues", "size", "issue_ratio"] +
list(pipeline.named_steps["preprocess"]
.named_transformers_["cat"]
.named_steps["encoder"]
.encoder.get_feature_names_out())
)
plt.figure(figsize=(8, 4))
plt.barh(feature_names, importances)
plt.xlabel("Importance")
plt.title("Feature Importances")
plt.tight_layout()
plt.savefig("feature_importance.png")
print("Saved feature importance chart as feature_importance.png")
if __name__ == "__main__":
main()
Expected Output
Running python script.py should print something akin to:
[HTTP] python/cpython: 403 Client Error: Forbidden for url: ...
Cross‑validated MAE: 12.34
Serialized pipeline to github_fork_predictor.joblib
Saved feature importance chart as feature_importance.png
The exact MAE will vary with live data, but the script will always produce a feature_importance.png file and a .joblib artifact.
Limitations and Tradeoffs
This approach assumes the API response size stays modest enough to fit in memory for a single training run; truly massive telemetry streams would require a distributed feature store (e.g., Feast) and incremental learning. The custom encoder adds a small maintenance burden—any change to the category set forces a model retrain. Finally, HistGradientBoostingRegressor works well for tabular data but cannot capture textual semantics; if you need to incorporate full README content, a hybrid deep‑learning model becomes necessary.
Frequently Asked Questions
Why not preprocess the JSON with pandas before feeding it to scikit‑learn?
Pandas introduces an extra copy of the data and hides the transformation logic from the pipeline, making leakage harder to detect. Embedding extraction inside a transformer keeps the entire workflow versioned and testable.
How does the pipeline behave when the API returns a new language?
The SafeCategoricalEncoder maps any unseen or None value to the “Other” bucket, preserving the feature dimension and preventing ValueError during transform.
Can I serialize this pipeline with joblib if it contains custom classes?
Yes. As long as the classes are importable at the top level (as they are in the script), joblib.dump and load work without extra configuration.
Is np.memmap safe for concurrent reads in a multi‑process serving environment?
Reading from a read‑only memmap is safe across processes, but writing must be coordinated. In production you would write once during training and open the file in read‑only mode for inference.
What if I need to predict on a single new repo without re‑loading the whole memmap?
You can call pipeline.predict([new_payload]). The extractor runs on the single dict, and the model uses the already‑trained parameters; the memmap is only a convenience for batch training.
What I'd Change
If I were to iterate on this design, I’d replace the in‑process HistGradientBoostingRegressor with a LightGBM model served via a REST endpoint, because LightGBM handles larger feature spaces and offers native categorical support—eliminating the need for a separate encoder. Moreover, I’d move the mem‑mapped matrix into a feature store backed by Parquet files, enabling Spark‑based feature generation for nightly retraining without pulling the entire raw JSON payload into Python memory.