Before We Begin
What is an exoplanet?
An exoplanet — short for extrasolar planet — is any planet that orbits a star other than our Sun. Our solar system has eight planets, but the Milky Way holds hundreds of billions of stars, and many of them have their own planets. Since the first confirmed detection in 1992, astronomers have found over 5,700 exoplanets, with thousands more awaiting confirmation.
How do we find them?
We can't photograph most exoplanets directly — they're too faint next to their host stars. Instead, we detect them indirectly: the transit method watches for tiny dips in starlight as a planet crosses in front of its star, and radial velocity measures the wobble a planet's gravity induces in its star. Both methods yield measurements like orbital period, star temperature, and planet radius — the raw features our model trains on.
Why does it matter?
Finding habitable exoplanets is the first step toward answering whether we're alone in the universe. A planet in the Habitable Zone — where temperatures allow liquid water — is the most promising candidate for hosting life. But with thousands of candidates, astronomers need machine learning to sift through the data and flag the most promising worlds for further study.
Step 01 — The Catalog
1 715 known exoplanets.
Every dot on this screen is a real, confirmed world discovered beyond our solar system — sourced directly from the NASA Exoplanet Archive via its TAP API. The data is queried as a SQL statement against the pscomppars table.
query = """
SELECT pl_orbper, pl_bmasse, pl_insol, pl_rade,
st_teff, st_logg, st_rad, st_mass, sy_vmag
FROM pscomppars
"""
url = "https://exoplanetarchive.ipac.caltech.edu/TAP/sync"
r = requests.get(url, params={"query": query, "format": "csv"})
df = pd.read_csv(io.StringIO(r.text))
From 5,715 to 1,715
The archive returns 5,715 confirmed planets — but not all have complete measurements. We drop rows where insolation or radius is missing, leaving 1,715 planets with the data we need. Then we create the target label: a planet is "habitable" if pl_insol is between 0.25–1.5 and pl_rade is between 0.5–1.6.
df["habitable"] = (
(df["pl_insol"].between(0.25, 1.5)) &
(df["pl_rade"].between(0.5, 1.6))
).astype(int)
# Only 0.6% are habitable — extreme imbalance
print(y.value_counts(normalize=True))
# 0 0.994
# 1 0.006
Step 02 — The Prediction
A Random Forest searches for candidates.
Here's the twist: the model never sees insolation or radius directly. Instead, it only uses seven stellar and orbital features to predict whether a planet falls in the habitable zone.
X = df[["pl_orbper", "pl_bmasse", "st_teff",
"st_logg", "st_rad", "st_mass", "sy_vmag"]]
y = df["habitable"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y
)
Why hide the obvious features?
This is a realistic scenario. When astronomers discover a new exoplanet, they often don't yet have precise radius or insolation measurements. But they do have data about the host star and the planet's orbit. The model learns to infer habitability from these indirect signals — mimicking how real exoplanet surveys work.
The 70/30 split
The data is split with test_size=0.3, stratify=y — 70% for training, 30% for testing, while preserving the ratio of habitable to non-habitable planets in both sets. Without stratification, the test set might end up with zero habitable planets by chance.
Step 03 — The Confirmation
Which predictions actually hold up?
Now we cross-reference the model's predictions against the actual habitability criteria. The teal dots are true hits — planets the model correctly identified. The faded dots were false alarms.
# Split training data further for validation
X_tr, X_val, y_tr, y_val = train_test_split(
X_train, y_train, test_size=0.25,
random_state=42, stratify=y_train
)
model.fit(X_tr, y_tr)
y_val_proba = model.predict_proba(X_val)[:, 1]
# Find threshold that achieves ≥ 90% recall
precision, recall, thresholds = precision_recall_curve(y_val, y_val_proba)
target_recall = 0.9
valid_idx = np.where(recall >= target_recall)[0]
chosen_threshold = thresholds[min(valid_idx[-1], len(thresholds)-1)]
# → Cutoff: 0.1700
The precision-recall tradeoff
The default threshold of 0.5 assumes balanced classes — but here, only 0.6% of planets are habitable. At 0.5, the model would predict nothing as habitable. By lowering the threshold to 0.17, we force the model to be more generous, catching 80% of truly habitable planets at the cost of more false positives. In astrobiology, missing a habitable world is worse than investigating a false lead.
Step 04 — Model Performance
The Confusion Matrix
This matrix breaks down every prediction the model made on the 1,715-planet test set. Hover over each cell to understand what it represents.
final_preds = (y_test_proba >= chosen_threshold).astype(int)
print(classification_report(y_test, final_preds, zero_division=0))
# precision recall f1-score support
# 0 1.00 0.99 0.99 1705
# 1 0.32 0.80 0.46 10
# accuracy 0.99 1715
Correctly identified as NOT habitable. These planets fall outside the habitable zone — too hot, too cold, or too large. The model correctly ignored them.
Predicted as habitable, but actually aren't. These are "false alarms" — the model was too optimistic. The low threshold (0.17) intentionally increases this number to avoid missing real candidates.
Actually habitable, but the model missed them. Only 2 out of 10 truly habitable planets were overlooked — a recall rate of 80%. These edge cases are the hardest to catch.
The stars of the show. These 8 planets are both predicted as habitable AND actually meet the habitability criteria. The model successfully found them using only indirect stellar features.
Step 05 — How It Works
The Pipeline
The model is a scikit-learn Pipeline — a chained sequence of transformers and a final estimator. Each step feeds its output into the next, ensuring no data leakage between training and testing.
model = make_pipeline(
SimpleImputer(strategy="median"),
StandardScaler(),
RandomForestClassifier(
n_estimators=100,
class_weight="balanced",
random_state=42
)
)
model.fit(X_train, y_train)
SimpleImputer(strategy="median")
Astronomical data is riddled with missing values — not every star has a measured surface gravity or visual magnitude. This step fills NaN values with the median of each feature (more robust to outliers than the mean). It's fitted only on training data, then applied to test data to prevent leakage.
StandardScaler()
Transforms each feature to zero mean and unit variance. Without this, the Random Forest would still work (tree-based models are scale-invariant), but the imputer and scaler together ensure clean, normalized input — and it matters if you ever swap in a distance-based model like SVM or KNN.
RandomForestClassifier
An ensemble of 100 decision trees, each trained on a bootstrap sample of the data and a random subset of features. The final prediction is the majority vote of all trees. Key parameter: class_weight="balanced" automatically upweights the rare habitable class by a factor of ~178× (1/0.006), preventing the model from simply predicting "not habitable" for everything.
Why "balanced" class weights?
With only 32 habitable planets out of 5,715, a naïve model would achieve 99.4% accuracy by always predicting "not habitable" — while finding zero habitable worlds. class_weight="balanced" penalizes misclassifying the rare class 178× more than the common class, forcing the model to take habitability seriously.
The export to JSON
After evaluation, the model's predictions on the full dataset are exported as planets.json — each record containing the planet's name, insolation, radius, actual label, predicted probability, and final prediction. This file is what powers the interactive visualization you're scrolling through right now.
End of Log
Scroll back up to see it again.
This project accompanies a Jupyter Notebook that walks through the entire ML pipeline — from querying the NASA Exoplanet Archive API to training and evaluating the model.