Cosmic Curator: Exoplanet Signal Lab
Build an interactive exoplanet classifier that reveals target leakage.
Introduction
30 Second Summary
A near-perfect score can look convincing at first glance. Hidden clues can make that score far less trustworthy than it seems.
In this project, you will build an interactive exoplanet signal lab from current NASA Exoplanet Archive data. It exposes target leakage before ranking unresolved candidates from physical measurements.
What You'll Build
Picture opening a deep-space dashboard where every hover reveals a Kepler signal.
By the end of this project, you'll have:
- An interactive population map where you can hover across confirmed planets, unresolved candidates, and false positives.
- An honest model comparison showing how leakage inflates ROC-AUC. You can compare a random forest with a logistic regression baseline on the same split.
- A public candidate observatory on GitHub Pages that ranks unresolved signals for educational review. GitHub Actions can rebuild the site from current NASA data.
- Secret Mission: Build a separate interactive view that highlights candidates where the logistic and random-forest probabilities disagree most.
Are there any prerequisites?
You'll need a GitHub account plus Python installed on macOS. The first step verifies your local tools before the data work begins.
Before We Start
Before any hands-on work, commit to what Cosmic Curator ranks. Its model probabilities support educational exploration only, with no claim of scientific validation or planet confirmation.
Prepare the ML Workspace
Reliable model comparisons require the same Python version across every run. An isolated virtual environment keeps Cosmic Curator's packages separate from the rest of your Mac.
This workspace gives every later model the same library versions. It also creates the source files that will hold your data pipeline and publishing workflow.
In this step, get ready to:
- Create the Cosmic Curator workspace in VS Code.
- Verify Python 3.11 or newer plus Git.
- Create an activated environment with the pinned data and machine learning packages.
Create the workspace skeleton
The workspace separates source code from generated dashboard files. Its configuration files also make the same package versions available on another machine.
- Press Command-Space bar to open Spotlight.
- Enter Finder in the search field.
- Press Return to open Finder.
- Press Shift-Command-D to open your Desktop.
- Press Shift-Command-N to create an empty folder.
- Enter cosmic-curator as the folder name.
- Press Return to save the folder name.
Your project now has a predictable home on the Desktop. That makes the folder easy to find from VS Code and the terminal.
- Press Command-Space bar to open Spotlight.
- Enter Visual Studio Code in the search field.
- Press Return to open VS Code.
- Select File from the menu bar.
- Select Open Folder.
- Choose the cosmic-curator folder on your Desktop.
- Select Open.
- Select Yes, I trust the authors if VS Code shows the Workspace Trust prompt.
You should see cosmic-curator at the top of the Explorer sidebar.
- Create curator.py with New File in the Explorer.
- Create requirements.txt with New File in the Explorer.
- Create .gitignore with New File in the Explorer.
- Create docs with New Folder in the Explorer.
- Create .github with New Folder in the Explorer.
The Explorer now shows the Python entry point and both configuration files. It also shows the empty output directory for the static dashboard.
- Select the .github folder in the Explorer.
- Create workflows with New Folder.
- Select the workflows folder in the Explorer.
- Create pages.yml with New File.
The empty pages.yml file reserves the documented workflow location. You will add the GitHub Pages build in the publishing step.
- Select requirements.txt in the Explorer.
- Add the pinned dependencies by copying this content into the file:
pandas==3.1.1
plotly==7.1.0
scikit-learn==1.9.1
What do these pins control?
- pandas 3.1.1 loads and transforms the Kepler data.
- Plotly 7.1.0 creates the interactive charts.
- scikit-learn 1.9.1 builds and evaluates the classification models.
- Press Command-S to save requirements.txt.
- Confirm that the editor shows exactly three dependency lines.
Does the dependency file look different?
Check that each package uses two equals signs before its version. Remove any spaces surrounding the equals signs.
Ask for help if the saved file still differs from the reference. Help me compare my requirements file with the three pinned dependencies.
- Select .gitignore in the Explorer.
- Add the local files that Git should ignore by copying this content into the file:
.venv/
__pycache__/
.DS_Store
What does this file protect?
- .venv/ keeps the machine-specific environment outside the future repository.
- __pycache__/ excludes Python's generated cache files.
- .DS_Store excludes Finder metadata created by macOS.
- Press Command-S to save .gitignore.
- Confirm that the editor shows exactly three ignore patterns.
Is the dotfile missing from the Explorer?
Confirm that the filename starts with a dot. Check that it has no extra file extension.
Use the Explorer filter if the file tree is crowded. Help me find my .gitignore file in VS Code.
Use this comparison to confirm the complete workspace state before installing packages.
✔️ Awesome, I've got everything!
Great. Save every open file before moving to the environment checks.
ⓧ I'd like to double check the full code
Your requirements.txt file should match this content.
pandas==3.1.1
plotly==7.1.0
scikit-learn==1.9.1
Your .gitignore file should match this content.
.venv/
__pycache__/
.DS_Store
The curator.py file should be empty. It is ready for the shared constants and reusable functions added in later steps.
The .github/workflows/pages.yml file should be empty. It is a placeholder for the later GitHub Pages workflow.
The docs folder should be empty. It will receive the generated static dashboard.
Verify Python and Git
The pinned pandas and scikit-learn releases require Python 3.11 or newer. Git must also be available before the project can be published.
- Select View from the VS Code menu bar.
- Select Terminal to open the integrated terminal inside cosmic-curator.
- Check the Python version by running this command:
python3 --version
This prints the Python version currently available through the python3 command.
✔️ I see version 3.11 or higher
Python meets the project's minimum version. Continue to the Git check below.
ⓧ I see an older version
Your current Python cannot install the pinned package versions. The official macOS installer can coexist with Apple's system-managed Python.
- Open the Python releases for macOS page.
- Choose a stable Python release that is 3.11 or newer.
- Select Download macOS installer for that release.
- Run the downloaded installer package with its standard options.
- Restart the VS Code terminal after the installer finishes.
- Confirm the new version by running this command:
python3 --version
You should now see Python 3.11 or newer.
Still seeing the older Python?
Close the existing terminal before opening a new one. This reloads the shell path updated by the installer.
If the older version remains, ask for help before creating the environment. Help me make VS Code use my newer Python installation on macOS.
ⓧ Command not found
Python needs to be installed before the environment can be created. The official macOS installer adds its executable to your shell path.
- Open the Python releases for macOS page.
- Choose a stable Python release that is 3.11 or newer.
- Select Download macOS installer for that release.
- Run the downloaded installer package with its standard options.
- Restart the VS Code terminal after the installer finishes.
- Confirm the installation by running this command:
python3 --version
You should see Python 3.11 or newer.
Python still unavailable?
Confirm that the installer completed successfully. Open a fresh terminal so it can load the updated shell path.
Ask for environment-specific help if the command still fails. Help me make the python3 command available in VS Code on macOS.
- Check whether Git is available by running this command:
git --version
This prints the installed Git version. macOS may offer to install its command line tools when Git is missing.
✔️ I see a Git version
Git is available for the publishing work later in the project.
ⓧ Command not found
The Git installation uses Apple's command line tools. macOS shows an installation dialog after the next command.
- Start the command line tools installer by running this command:
xcode-select --install
This requests Apple's command line tools package, which includes Git.
- Approve the installation in the macOS dialog.
- Wait for the installation to complete.
- Confirm Git is available by running this command:
git --version
You should now see a Git version in the terminal.
Git still unavailable?
Restart the VS Code terminal after the command line tools finish installing. Confirm that the installation dialog reported success.
Ask for help if the version check still fails. Help me troubleshoot Git after installing the macOS command line tools.
Create the isolated environment
The virtual environment locks this workspace to the verified interpreter. Package installation inside it cannot overwrite libraries used by your other Python projects.
- Create .venv inside cosmic-curator by running this command:
python3 -m venv .venv
What does this command create?
Python creates an isolated interpreter plus its package directories inside .venv. The ignore rule keeps that machine-specific folder outside the future repository.
- Confirm that .venv appears in the Explorer.
Did the environment creation fail?
Check that the terminal is inside the cosmic-curator folder. Confirm that the Python check showed 3.11 or newer.
Ask for help with the exact terminal output. Help me troubleshoot why Python cannot create .venv in cosmic-curator.
- Activate the new environment by running this command:
source .venv/bin/activate
What does activation change?
Activation makes python and pip point to the copies inside .venv. Your terminal prompt should now begin with (.venv).
- Confirm that (.venv) appears at the start of the terminal prompt.
Is the environment still inactive?
Confirm that the terminal is inside cosmic-curator. Check that the Explorer contains the .venv folder.
Ask for help if the prompt does not change. Help me activate .venv in the VS Code terminal on macOS.
The installation can pause while the three pinned packages download. Keep the terminal open until the command returns to the prompt.
- Install the pinned dependencies into the activated environment by running this command:
python -m pip install -r requirements.txt
What does this install?
pip reads every exact version from requirements.txt. It installs those packages inside the active .venv environment.
- Wait until the terminal returns to the (.venv) prompt.
Did package installation fail?
Confirm that the prompt begins with (.venv). Compare requirements.txt with the full-code reference above.
Network interruptions can also stop package downloads. Help me troubleshoot my pinned package installation.
Before the final check, predict which Python version and package versions the activated environment should report.
- Verify Python, Git, and all three installed packages by running these commands:
python --version
git --version
python -m pip show pandas scikit-learn plotly
What should I see?
- The Python line reports version 3.11 or newer.
- The Git line reports an installed version.
- The package records report pandas 3.1.1.
- The remaining package records report scikit-learn 1.9.1 plus Plotly 7.1.0.
Your reproducible workspace is ready. Cosmic Curator now has a verified interpreter plus the exact libraries required for its data pipeline and interactive dashboard.
Next, you will retrieve the Kepler population and turn its physical measurements into your first interactive map.
Map the Kepler Population
Your Python workspace is ready to turn live scientific measurements into the first Cosmic Curator view.
Before training a classifier, you need to inspect the physical measurements from the NASA Exoplanet Archive. Missing values shape which rows the models can use. Extreme scales shape how the population needs to be plotted.
In this step, get ready to:
- Retrieve the cumulative Kepler Objects of Interest table through TAP.
- Prepare valid plotting data from the returned measurements.
- Generate a self-contained interactive population map.
Retrieve the archive data
The TAP endpoint accepts an ADQL query through a URL. The archive responds with CSV data without requiring an API key.
- Replace the placeholder text in curator.py with these imports plus the physical feature list:
from pathlib import Path
from string import Template
from urllib.parse import urlencode
import pandas as pd
import plotly.express as px
from plotly.io import to_html
RANDOM_STATE = 42
TAP_ENDPOINT = "https://exoplanetarchive.ipac.caltech.edu/TAP/sync"
PHYSICAL_FEATURES = [
"koi_period",
"koi_impact",
"koi_duration",
"koi_depth",
"koi_prad",
"koi_teq",
"koi_insol",
"koi_model_snr",
"koi_steff",
"koi_slogg",
"koi_srad",
"koi_kepmag",
]
What does this section define?
- The imports provide file handling for the generated dashboard.
- The imports also provide URL encoding for the archive request.
- The PHYSICAL_FEATURES list contains 12 transit or stellar measurements.
- The fixed RANDOM_STATE value keeps later model comparisons reproducible.
- Continue curator.py with the leakage fields plus the readable feature labels:
LEAKAGE_FEATURES = [
"koi_score",
"koi_fpflag_nt",
"koi_fpflag_ss",
"koi_fpflag_co",
"koi_fpflag_ec",
]
QUERY_COLUMNS = ["kepoi_name", "koi_disposition", *PHYSICAL_FEATURES, *LEAKAGE_FEATURES]
FEATURE_LABELS = {
"koi_period": "Orbital period",
"koi_impact": "Impact parameter",
"koi_duration": "Transit duration",
"koi_depth": "Transit depth",
"koi_prad": "Planetary radius",
"koi_teq": "Equilibrium temperature",
"koi_insol": "Insolation flux",
"koi_model_snr": "Transit signal-to-noise",
"koi_steff": "Stellar temperature",
"koi_slogg": "Stellar surface gravity",
"koi_srad": "Stellar radius",
"koi_kepmag": "Kepler-band magnitude",
}
Why separate these fields?
The physical list contains measurements from the transit signal or host star. The leakage list contains fields connected to the archive's existing disposition process.
The request retrieves both groups for the later feature audit. FEATURE_LABELS provides readable chart names.
- Save curator.py.
- Confirm the feature counts by running this command:
python -c "import curator; print(len(curator.PHYSICAL_FEATURES), len(curator.LEAKAGE_FEATURES))"
What should this show?
The terminal prints 12 5. Those counts confirm that the physical fields and disposition-derived fields remain separate.
Your feature groups are now ready for a controlled audit. The archive loader can use one combined column list without losing that distinction.
A DataFrame keeps every returned column aligned. It also preserves missing measurements for the plotting preparation.
- Add the disposition colors plus the reusable data and chart helpers below FEATURE_LABELS:
DISPOSITION_COLORS = {
"CONFIRMED": "#78f7d4",
"CANDIDATE": "#a78bfa",
"FALSE POSITIVE": "#ff6b9d",
}
def fetch_data():
query = f"select {','.join(QUERY_COLUMNS)} from cumulative"
request_url = f"{TAP_ENDPOINT}?{urlencode({'query': query, 'format': 'csv'})}"
return pd.read_csv(request_url)
def chart_html(figure, include_library=False):
return to_html(
figure,
full_html=False,
include_plotlyjs=include_library,
config={"displaylogo": False, "responsive": True},
)
How does the loader work?
- The DISPOSITION_COLORS dictionary assigns a consistent color to each archive disposition.
- The query selects every field in QUERY_COLUMNS from the cumulative table.
- The urlencode() call safely places the query inside the request URL.
- The pd.read_csv() call converts the CSV response into a DataFrame.
- Save curator.py.
Before you fetch the table, what mix of confirmed objects, unresolved candidates, and false positives do you expect to see?
The live request can take a few seconds while the archive prepares the CSV response. A short pause in the terminal is expected.
- Fetch the current table and display its shape plus disposition counts by running:
python -c "from curator import fetch_data; data = fetch_data(); print(data.shape); print(data['koi_disposition'].value_counts())"
What does this check prove?
The first line shows the returned row and column counts. The following lines show the archive dispositions represented in the table.
This confirms that fetch_data() can retrieve current KOI measurements without a user-managed secret.
That is your first live scientific-data milestone. Cosmic Curator can now retrieve the archive population directly from its source.
Data request not returning?
Confirm that the terminal prompt still shows the activated virtual environment. Check that your Mac has an active internet connection.
Compare TAP_ENDPOINT with the code above. A missing character prevents the request from reaching the archive.
Help me diagnose the TAP data request.
Build the population map
Orbital period spans a wide range. Planetary radius does too.
Logarithmic axes keep compact objects visible beside extreme objects. Both axes require positive values.
- Add the first build_dashboard() implementation below chart_html():
def build_dashboard(data):
plot_data = data[(data["koi_period"] > 0) & (data["koi_prad"] > 0)].copy()
plot_data["signal_size"] = plot_data["koi_model_snr"].clip(lower=0).fillna(0) + 1
population_figure = px.scatter(
plot_data,
x="koi_period",
y="koi_prad",
color="koi_disposition",
size="signal_size",
hover_name="kepoi_name",
hover_data=["koi_duration", "koi_depth", "koi_teq", "koi_model_snr"],
color_discrete_map=DISPOSITION_COLORS,
size_max=18,
log_x=True,
log_y=True,
opacity=0.72,
labels={
"koi_period": "Orbital period in days",
"koi_prad": "Planetary radius in Earth radii",
"koi_disposition": "Archive disposition",
},
title="The Kepler candidate population",
template="plotly_dark",
height=590,
)
How is the map prepared?
- The first expression removes rows with non-positive orbital periods or planetary radii.
- The signal_size column uses transit signal-to-noise for marker size.
- The clip() call prevents negative marker sizes.
- The fillna() call gives missing signal values a usable base size.
The scatter plot uses archive disposition for color. Its hover panel exposes the KOI name plus selected physical measurements.
- Continue build_dashboard() with the static page template plus the file-writing logic:
page_template = Template(
"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cosmic Curator | Exoplanet Signal Lab</title>
<style>
:root { color-scheme: dark; }
.shell { position: relative; width: min(1180px, 92vw); margin: 0 auto; padding: 64px 0 90px; }
</style>
</head>
<body>
<main class="shell">$population_chart</main>
</body>
</html>"""
)
page = page_template.substitute(
population_chart=chart_html(population_figure, include_library=True),
)
output_directory = Path("docs")
output_directory.mkdir(exist_ok=True)
output_path = output_directory / "index.html"
output_path.write_text(page, encoding="utf-8")
return {
"rows": len(data),
"output": output_path,
}
How does the page stay self-contained?
The template places the chart inside a dark document shell. The responsive width keeps the map usable on smaller screens.
The include_library=True setting embeds the Plotly JavaScript bundle. The generated file can open without a runtime server.
- Save curator.py.
Before you generate the map, where do you expect the three archive dispositions to overlap?
- Build the first dashboard file directly from the two functions by running:
python -c "from curator import build_dashboard, fetch_data; print(build_dashboard(fetch_data()))"
What should the build return?
The terminal prints a dictionary containing the retrieved row count. It also shows docs/index.html as the output path.
The new HTML file confirms that the complete data-to-chart path has run successfully.
You have turned a live scientific catalog into a local dashboard file. The next check reveals whether the plotting decisions produced a useful map.
Dashboard file not created?
Check that the page-template block remains indented inside build_dashboard(). Confirm that include_library=True appears in the chart conversion.
Verify that docs/index.html appears in the VS Code Explorer sidebar. A missing file means the function stopped before the write step.
Help me fix my missing population map.
Generate and inspect the dashboard
A command-line entry point turns the helper functions into a repeatable build. Each run fetches the current archive table before replacing the generated dashboard.
- Add the entry point below build_dashboard():
def main():
results = build_dashboard(fetch_data())
print(f"Built {results['output']} from {results['rows']:,} KOIs")
if __name__ == "__main__":
main()
What does the entry point add?
The main() function runs the complete retrieval and dashboard build. It prints the output path plus the number of KOIs after a successful build.
The final condition runs main() only when you execute curator.py directly.
- Save curator.py.
Before you run the finished script, what terminal confirmation do you expect after the archive becomes a dashboard?
- Build the dashboard from the activated environment by running:
python curator.py
What should the terminal confirm?
The output starts with Built docs/index.html from. It finishes with the number of retrieved KOIs.
- Open the generated dashboard in your default browser by running:
open docs/index.html
What should you see?
You should see a dark interactive chart titled The Kepler candidate population. Hovering over a marker reveals its KOI name plus physical measurements.
Confirmed objects appear in mint. Candidates appear in violet. False positives appear in pink.
Both axes use logarithmic scales. Marker sizes reflect transit signal-to-noise after negative and missing values receive safe plotting sizes.
That is the first Cosmic Curator view working end to end. Live archive measurements now form an interactive population map you can inspect in the browser.
Map missing or blank?
Confirm that the terminal prompt still shows the activated virtual environment. Run the build from inside the cosmic-curator folder.
Check that main() sits outside build_dashboard(). It must have no leading indentation.
Help me debug the dashboard build.
✔️ Awesome, I've got everything!
- Save curator.py so the working population map is ready for the next step.
ⓧ I'd like to double check the full code
from pathlib import Path
from string import Template
from urllib.parse import urlencode
import pandas as pd
import plotly.express as px
from plotly.io import to_html
RANDOM_STATE = 42
TAP_ENDPOINT = "https://exoplanetarchive.ipac.caltech.edu/TAP/sync"
PHYSICAL_FEATURES = [
"koi_period",
"koi_impact",
"koi_duration",
"koi_depth",
"koi_prad",
"koi_teq",
"koi_insol",
"koi_model_snr",
"koi_steff",
"koi_slogg",
"koi_srad",
"koi_kepmag",
]
LEAKAGE_FEATURES = [
"koi_score",
"koi_fpflag_nt",
"koi_fpflag_ss",
"koi_fpflag_co",
"koi_fpflag_ec",
]
QUERY_COLUMNS = ["kepoi_name", "koi_disposition", *PHYSICAL_FEATURES, *LEAKAGE_FEATURES]
FEATURE_LABELS = {
"koi_period": "Orbital period",
"koi_impact": "Impact parameter",
"koi_duration": "Transit duration",
"koi_depth": "Transit depth",
"koi_prad": "Planetary radius",
"koi_teq": "Equilibrium temperature",
"koi_insol": "Insolation flux",
"koi_model_snr": "Transit signal-to-noise",
"koi_steff": "Stellar temperature",
"koi_slogg": "Stellar surface gravity",
"koi_srad": "Stellar radius",
"koi_kepmag": "Kepler-band magnitude",
}
DISPOSITION_COLORS = {
"CONFIRMED": "#78f7d4",
"CANDIDATE": "#a78bfa",
"FALSE POSITIVE": "#ff6b9d",
}
def fetch_data():
query = f"select {','.join(QUERY_COLUMNS)} from cumulative"
request_url = f"{TAP_ENDPOINT}?{urlencode({'query': query, 'format': 'csv'})}"
return pd.read_csv(request_url)
def chart_html(figure, include_library=False):
return to_html(
figure,
full_html=False,
include_plotlyjs=include_library,
config={"displaylogo": False, "responsive": True},
)
def build_dashboard(data):
plot_data = data[(data["koi_period"] > 0) & (data["koi_prad"] > 0)].copy()
plot_data["signal_size"] = plot_data["koi_model_snr"].clip(lower=0).fillna(0) + 1
population_figure = px.scatter(
plot_data,
x="koi_period",
y="koi_prad",
color="koi_disposition",
size="signal_size",
hover_name="kepoi_name",
hover_data=["koi_duration", "koi_depth", "koi_teq", "koi_model_snr"],
color_discrete_map=DISPOSITION_COLORS,
size_max=18,
log_x=True,
log_y=True,
opacity=0.72,
labels={
"koi_period": "Orbital period in days",
"koi_prad": "Planetary radius in Earth radii",
"koi_disposition": "Archive disposition",
},
title="The Kepler candidate population",
template="plotly_dark",
height=590,
)
page_template = Template(
"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cosmic Curator | Exoplanet Signal Lab</title>
<style>
:root { color-scheme: dark; }
.shell { position: relative; width: min(1180px, 92vw); margin: 0 auto; padding: 64px 0 90px; }
</style>
</head>
<body>
<main class="shell">$population_chart</main>
</body>
</html>"""
)
page = page_template.substitute(
population_chart=chart_html(population_figure, include_library=True),
)
output_directory = Path("docs")
output_directory.mkdir(exist_ok=True)
output_path = output_directory / "index.html"
output_path.write_text(page, encoding="utf-8")
return {
"rows": len(data),
"output": output_path,
}
def main():
results = build_dashboard(fetch_data())
print(f"Built {results['output']} from {results['rows']:,} KOIs")
if __name__ == "__main__":
main()
Your archive data now opens as an interactive scientific story. Next, you will test what happens when a classifier learns from the wrong kind of evidence.
Expose the Leakage Trap
Your population map proves that the Kepler measurements reach the dashboard. A classifier now asks a harder question: can those measurements separate confirmed objects from false positives?
A convincing score can hide target leakage when the inputs encode the archive's existing verdict. You will deliberately train that shortcut first. The result reveals why model performance must be judged alongside feature legitimacy.
In this step, get ready to:
- Build a class-balanced random-forest pipeline with median imputation.
- Create one reproducible stratified train and test split.
- Run the leaky model and expose its misleading result on the dashboard.
Build the forest pipeline
A random forest can capture nonlinear relationships between transit measurements. A Pipeline keeps missing-value imputation attached to the model so every training or prediction call receives the same preparation.
- In curator.py, add the following imports below the existing Plotly imports:
from sklearn.ensemble import RandomForestClassifier
from sklearn.impute import SimpleImputer
from sklearn.metrics import confusion_matrix, roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
What do these imports provide?
- Scikit-learn supplies the model preparation and evaluation tools for this experiment.
- Median imputation fills missing numeric measurements using the middle observed value for each feature.
- The split and metric functions let every model use the same held-out rows.
- Save curator.py.
- Confirm that the new imports load by running the dashboard:
python curator.py
What does this check prove?
You should see the dashboard build message from the previous step. That successful run confirms Python can load the new scikit-learn classes without an import or syntax error.
Seeing an import error?
Confirm that your terminal prompt still shows the activated virtual environment. Run the package installation command from Step 1 again if scikit-learn is missing.
Ask for help with the import failure.
The forest needs a consistent way to handle incomplete archive measurements. The model also needs class balancing because confirmed objects and false positives may appear in different quantities.
- In curator.py, add make_forest() below fetch_data() by pasting this function:
def make_forest():
return Pipeline(
[
("imputer", SimpleImputer(strategy="median")),
(
"model",
RandomForestClassifier(
n_estimators=350,
min_samples_leaf=4,
class_weight="balanced",
random_state=RANDOM_STATE,
n_jobs=-1,
),
),
]
)
What does this pipeline do?
- The imputer step replaces missing feature values with each column's median.
- The forest uses 350 trees to learn repeated patterns across the selected inputs.
- Class balancing gives each target class equal influence during training.
- The shared RANDOM_STATE makes the fitted experiment reproducible.
- Save curator.py.
- Check that the new function parses successfully by running the dashboard:
python curator.py
What should you see?
You should see the existing build message again. This confirms that Python can construct the forest pipeline definition without stopping the dashboard.
Seeing a syntax error?
Check the indentation inside make_forest(). Each named pipeline step must remain inside the surrounding list.
Ask for help checking the pipeline structure.
Train on the existing verdict
The supervised experiment needs rows with known archive dispositions. A stratified split preserves the target class proportions while reserving 25 percent of those rows for testing.
The first feature set combines physical measurements with every disposition-derived field already retrieved. Run this version before deciding whether its score deserves your trust.
- In build_dashboard(), add this model preparation directly below the function definition:
labeled = data[
data["koi_disposition"].isin(["CONFIRMED", "FALSE POSITIVE"])
].copy()
labeled["target"] = (labeled["koi_disposition"] == "CONFIRMED").astype(int)
train_index, test_index = train_test_split(
labeled.index,
test_size=0.25,
random_state=RANDOM_STATE,
stratify=labeled["target"],
)
y_train = labeled.loc[train_index, "target"]
y_test = labeled.loc[test_index, "target"]
leaky_features = [*PHYSICAL_FEATURES, *LEAKAGE_FEATURES]
leaky_forest = make_forest()
leaky_forest.fit(labeled.loc[train_index, leaky_features], y_train)
leaky_probability = leaky_forest.predict_proba(
labeled.loc[test_index, leaky_features]
)[:, 1]
leaky_auc = roc_auc_score(y_test, leaky_probability)
How does this experiment work?
- The target becomes 1 for CONFIRMED rows. It becomes 0 for FALSE POSITIVE rows.
- The model learns from 75 percent of the labeled rows. The remaining 25 percent provide a held-out test.
- The leaky_features list combines all 12 physical measurements with the five disposition-derived fields.
- ROC-AUC measures how well the model ranks confirmed objects above false positives across classification thresholds.
- Replace the return block and main() section at the bottom of curator.py with this version:
return {
"rows": len(data),
"leaky_auc": leaky_auc,
"output": output_path,
}
def main():
results = build_dashboard(fetch_data())
print(f"Built {results['output']} from {results['rows']:,} KOIs")
print(f"Leaky forest ROC-AUC: {results['leaky_auc']:.3f}")
if __name__ == "__main__":
main()
Why return the score?
The return dictionary carries the held-out score into main(). Printing it makes the model's apparent success visible before the dashboard explains what caused it.
- Save curator.py.
Before you run this version, do you expect the new forest to produce a weak score or a suspiciously strong one?
- Run the leaky experiment with this command:
python curator.py
What just happened?
You should see Leaky forest ROC-AUC: followed by a high decimal. The exact value can change as the live archive data changes.
The model appears excellent because its inputs include information tied to the archive's existing vetting decision. High held-out performance alone cannot make that feature set legitimate.
That is the trap exposed. Your model passed a numerical test while failing the more important question of whether its predictors belong in an honest classification task.
Model run failing?
Check that the new model code sits at the start of build_dashboard(). Confirm that the original plotting code remains indented inside the same function.
Ask for help locating the failing model stage.
Put the trap on the dashboard
The terminal score proves the model can rank the held-out rows. The Plotly dashboard now needs to show that score beside a visible explanation of the contaminated inputs.
- In build_dashboard(), add the score chart directly below the existing population chart definition:
model_scores = pd.DataFrame(
[
{"model": "Leaky forest", "roc_auc": leaky_auc, "kind": "Do not trust"},
]
)
score_figure = px.bar(
model_scores,
x="model",
y="roc_auc",
color="kind",
text_auto=".3f",
color_discrete_map={"Do not trust": "#ff6b9d", "Audited": "#78f7d4"},
range_y=[0.5, 1.0],
labels={"model": "", "roc_auc": "Test ROC-AUC", "kind": "Feature audit"},
title="Performance before and after the feature audit",
template="plotly_dark",
height=440,
)
What does this chart communicate?
The chart turns the held-out score into a visible metric. Its warning color and Do not trust label separate numerical performance from feature legitimacy.
- Inside page_template, add this warning and score card below the population-map section:
<section class="warning">
<strong>The trap:</strong> `koi_score` and the four `koi_fpflag_*` fields summarize or contribute to Kepler's existing disposition process. A model trained with them can reproduce the answer it is supposed to predict. Cosmic Curator shows that result, then removes those fields.
</section>
<div class="grid">
<section class="card">
<div class="section-copy"><h2>Too good, then audited</h2><p>The honest comparison uses one held-out split for every model. The score usually falls after leakage is removed, and that is a feature of the audit rather than a failure.</p></div>
$score_chart
</section>
</div>
Why name every leaking field?
The warning identifies koi_score, koi_fpflag_nt, koi_fpflag_ss, koi_fpflag_co, and koi_fpflag_ec as disposition-derived information. Naming the contaminated inputs makes the audit reproducible.
- In the existing page_template.substitute() call, add this chart substitution below population_chart.
score_chart=chart_html(score_figure),
What does this line connect?
The substitution converts the Plotly score figure into HTML. The template inserts that HTML wherever it finds $score_chart.
- Save curator.py.
Before the final run, what should the page communicate beyond the model's score?
- Rebuild the dashboard with this command:
python curator.py
What should the terminal confirm?
You should see the path to docs/index.html followed by the leaky forest's held-out ROC-AUC. This confirms the generated file contains the latest experiment.
- Return to the browser tab containing the Cosmic Curator dashboard.
- Refresh the page.
You should see the interactive population map plus a leaky forest score card. A visible warning names koi_score and the four false-positive flags as disposition-derived leakage.
You caught the model's shortcut before treating it as scientific insight. The dashboard now demonstrates why a strong score can still describe an invalid experiment.
Score card missing?
Confirm that $score_chart appears inside the page template. Confirm that score_chart=chart_html(score_figure) appears in the substitution call.
Ask for help tracing the missing chart.
✔️ Awesome, I've got everything!
Great. Double-check that curator.py is saved before you continue.
ⓧ I'd like to double check the full code
from datetime import datetime, timezone
from pathlib import Path
from string import Template
from urllib.parse import urlencode
import pandas as pd
import plotly.express as px
from plotly.io import to_html
from sklearn.ensemble import RandomForestClassifier
from sklearn.impute import SimpleImputer
from sklearn.metrics import confusion_matrix, roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
RANDOM_STATE = 42
TAP_ENDPOINT = "https://exoplanetarchive.ipac.caltech.edu/TAP/sync"
PHYSICAL_FEATURES = [
"koi_period",
"koi_impact",
"koi_duration",
"koi_depth",
"koi_prad",
"koi_teq",
"koi_insol",
"koi_model_snr",
"koi_steff",
"koi_slogg",
"koi_srad",
"koi_kepmag",
]
LEAKAGE_FEATURES = [
"koi_score",
"koi_fpflag_nt",
"koi_fpflag_ss",
"koi_fpflag_co",
"koi_fpflag_ec",
]
QUERY_COLUMNS = ["kepoi_name", "koi_disposition", *PHYSICAL_FEATURES, *LEAKAGE_FEATURES]
FEATURE_LABELS = {
"koi_period": "Orbital period",
"koi_impact": "Impact parameter",
"koi_duration": "Transit duration",
"koi_depth": "Transit depth",
"koi_prad": "Planetary radius",
"koi_teq": "Equilibrium temperature",
"koi_insol": "Insolation flux",
"koi_model_snr": "Transit signal-to-noise",
"koi_steff": "Stellar temperature",
"koi_slogg": "Stellar surface gravity",
"koi_srad": "Stellar radius",
"koi_kepmag": "Kepler-band magnitude",
}
DISPOSITION_COLORS = {
"CONFIRMED": "#78f7d4",
"CANDIDATE": "#a78bfa",
"FALSE POSITIVE": "#ff6b9d",
}
def fetch_data():
query = f"select {','.join(QUERY_COLUMNS)} from cumulative"
request_url = f"{TAP_ENDPOINT}?{urlencode({'query': query, 'format': 'csv'})}"
return pd.read_csv(request_url)
def make_forest():
return Pipeline(
[
("imputer", SimpleImputer(strategy="median")),
(
"model",
RandomForestClassifier(
n_estimators=350,
min_samples_leaf=4,
class_weight="balanced",
random_state=RANDOM_STATE,
n_jobs=-1,
),
),
]
)
def chart_html(figure, include_library=False):
return to_html(
figure,
full_html=False,
include_plotlyjs=include_library,
config={"displaylogo": False, "responsive": True},
)
def build_dashboard(data):
labeled = data[
data["koi_disposition"].isin(["CONFIRMED", "FALSE POSITIVE"])
].copy()
labeled["target"] = (labeled["koi_disposition"] == "CONFIRMED").astype(int)
train_index, test_index = train_test_split(
labeled.index,
test_size=0.25,
random_state=RANDOM_STATE,
stratify=labeled["target"],
)
y_train = labeled.loc[train_index, "target"]
y_test = labeled.loc[test_index, "target"]
leaky_features = [*PHYSICAL_FEATURES, *LEAKAGE_FEATURES]
leaky_forest = make_forest()
leaky_forest.fit(labeled.loc[train_index, leaky_features], y_train)
leaky_probability = leaky_forest.predict_proba(
labeled.loc[test_index, leaky_features]
)[:, 1]
leaky_auc = roc_auc_score(y_test, leaky_probability)
plot_data = data[(data["koi_period"] > 0) & (data["koi_prad"] > 0)].copy()
plot_data["signal_size"] = plot_data["koi_model_snr"].clip(lower=0).fillna(0) + 1
population_figure = px.scatter(
plot_data,
x="koi_period",
y="koi_prad",
color="koi_disposition",
size="signal_size",
hover_name="kepoi_name",
hover_data=["koi_duration", "koi_depth", "koi_teq", "koi_model_snr"],
color_discrete_map=DISPOSITION_COLORS,
size_max=18,
log_x=True,
log_y=True,
opacity=0.72,
labels={
"koi_period": "Orbital period in days",
"koi_prad": "Planetary radius in Earth radii",
"koi_disposition": "Archive disposition",
},
title="The Kepler candidate population",
template="plotly_dark",
height=590,
)
model_scores = pd.DataFrame(
[
{"model": "Leaky forest", "roc_auc": leaky_auc, "kind": "Do not trust"},
]
)
score_figure = px.bar(
model_scores,
x="model",
y="roc_auc",
color="kind",
text_auto=".3f",
color_discrete_map={"Do not trust": "#ff6b9d", "Audited": "#78f7d4"},
range_y=[0.5, 1.0],
labels={"model": "", "roc_auc": "Test ROC-AUC", "kind": "Feature audit"},
title="Performance before and after the feature audit",
template="plotly_dark",
height=440,
)
counts = data["koi_disposition"].value_counts()
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
page_template = Template(
"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cosmic Curator | Exoplanet Signal Lab</title>
<style>
:root {
color-scheme: dark;
--ink: #f7f5ff;
--muted: #aaa6c9;
--violet: #8b5cf6;
--pink: #ec4899;
--mint: #78f7d4;
--panel: rgba(19, 15, 43, 0.74);
--line: rgba(196, 181, 253, 0.18);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background:
radial-gradient(circle at 12% 8%, rgba(124, 58, 237, 0.34), transparent 28rem),
radial-gradient(circle at 88% 18%, rgba(236, 72, 153, 0.23), transparent 24rem),
radial-gradient(circle at 54% 88%, rgba(45, 212, 191, 0.16), transparent 28rem),
#070611;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.42;
background-image:
radial-gradient(circle, rgba(255,255,255,.9) 0 1px, transparent 1.5px),
radial-gradient(circle, rgba(167,139,250,.8) 0 1px, transparent 1.5px);
background-position: 0 0, 36px 42px;
background-size: 92px 92px, 137px 137px;
mask-image: linear-gradient(to bottom, black, transparent 82%);
}
a { color: var(--mint); }
.shell { position: relative; width: min(1180px, 92vw); margin: 0 auto; padding: 64px 0 90px; }
.hero { padding: 62px 0 38px; }
.eyebrow {
display: inline-flex;
gap: 10px;
align-items: center;
padding: 8px 13px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--mint);
background: rgba(120,247,212,.07);
letter-spacing: .16em;
text-transform: uppercase;
font-size: .76rem;
font-weight: 750;
}
h1 {
max-width: 900px;
margin: 24px 0 16px;
font-size: clamp(3.2rem, 8vw, 7.2rem);
line-height: .88;
letter-spacing: -.065em;
background: linear-gradient(115deg, #fff 8%, #c4b5fd 47%, #78f7d4 92%);
-webkit-background-clip: text;
color: transparent;
}
.lede { max-width: 760px; color: #cbc7e6; font-size: clamp(1.05rem, 2vw, 1.35rem); line-height: 1.7; }
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin: 34px 0; }
.stat, .card {
border: 1px solid var(--line);
background: linear-gradient(145deg, rgba(32,25,69,.84), var(--panel));
box-shadow: 0 22px 70px rgba(0,0,0,.28), inset 0 1px rgba(255,255,255,.04);
backdrop-filter: blur(18px);
}
.stat { padding: 20px; border-radius: 22px; }
.stat strong { display: block; font-size: 1.75rem; letter-spacing: -.04em; }
.stat span { color: var(--muted); font-size: .82rem; text-transform: uppercase; letter-spacing: .1em; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.card { border-radius: 28px; overflow: hidden; padding: 18px; margin: 18px 0; }
.card.wide { grid-column: 1 / -1; }
.section-copy { padding: 14px 14px 2px; }
.section-copy h2 { margin: 0 0 8px; font-size: clamp(1.55rem, 3vw, 2.25rem); letter-spacing: -.035em; }
.section-copy p { color: var(--muted); line-height: 1.65; margin: 0; }
.warning {
margin: 18px 0;
padding: 22px;
border-radius: 22px;
border: 1px solid rgba(255,107,157,.34);
background: linear-gradient(135deg, rgba(255,107,157,.14), rgba(124,58,237,.1));
}
.warning strong { color: #ff9bbd; }
.footer { color: var(--muted); padding-top: 28px; line-height: 1.7; }
@media (max-width: 860px) {
.stats { grid-template-columns: 1fr 1fr; }
.grid { grid-template-columns: 1fr; }
.card.wide { grid-column: auto; }
}
@media (max-width: 520px) {
.stats { grid-template-columns: 1fr; }
.shell { width: min(94vw, 1180px); padding-top: 24px; }
.card { padding: 8px; border-radius: 20px; }
}
</style>
</head>
<body>
<main class="shell">
<header class="hero">
<span class="eyebrow">NASA data · honest ML · interactive story</span>
<h1>Cosmic Curator</h1>
<p class="lede">An exoplanet signal lab that turns Kepler transit measurements into an interactive lesson about classification, target leakage, and responsible model interpretation.</p>
<div class="stats">
<div class="stat"><strong>$total_rows</strong><span>KOIs retrieved</span></div>
<div class="stat"><strong>$confirmed_rows</strong><span>Confirmed</span></div>
<div class="stat"><strong>$candidate_rows</strong><span>Candidates</span></div>
<div class="stat"><strong>$false_positive_rows</strong><span>False positives</span></div>
</div>
</header>
<section class="card wide">
<div class="section-copy"><h2>A sky full of signals</h2><p>Use the legend, hover labels, zoom, and box selection to explore how transit period, estimated radius, and signal-to-noise vary across archive dispositions.</p></div>
$population_chart
</section>
<section class="warning">
<strong>The trap:</strong> `koi_score` and the four `koi_fpflag_*` fields summarize or contribute to Kepler's existing disposition process. A model trained with them can reproduce the answer it is supposed to predict. Cosmic Curator shows that result, then removes those fields.
</section>
<div class="grid">
<section class="card">
<div class="section-copy"><h2>Too good, then audited</h2><p>The honest comparison uses one held-out split for every model. The score usually falls after leakage is removed, and that is a feature of the audit rather than a failure.</p></div>
$score_chart
</section>
</div>
<footer class="footer">
Generated $generated_at from the NASA Exoplanet Archive KOI cumulative table. Test ROC-AUC: leaky forest $leaky_auc.
</footer>
</main>
</body>
</html>"""
)
page = page_template.substitute(
total_rows=f"{len(data):,}",
confirmed_rows=f"{counts.get('CONFIRMED', 0):,}",
candidate_rows=f"{counts.get('CANDIDATE', 0):,}",
false_positive_rows=f"{counts.get('FALSE POSITIVE', 0):,}",
population_chart=chart_html(population_figure, include_library=True),
score_chart=chart_html(score_figure),
generated_at=generated_at,
leaky_auc=f"{leaky_auc:.3f}",
)
output_directory = Path("docs")
output_directory.mkdir(exist_ok=True)
output_path = output_directory / "index.html"
output_path.write_text(page, encoding="utf-8")
return {
"rows": len(data),
"leaky_auc": leaky_auc,
"output": output_path,
}
def main():
results = build_dashboard(fetch_data())
print(f"Built {results['output']} from {results['rows']:,} KOIs")
print(f"Leaky forest ROC-AUC: {results['leaky_auc']:.3f}")
if __name__ == "__main__":
main()
Use this file as a comparison reference. It contains the population map, reproducible leaky experiment, visible score, and target-leakage warning from this step.
The shortcut is now visible instead of hidden behind a strong metric. Next, you will remove the disposition-derived fields and see how two audited models perform using physical measurements alone.
Audit Features and Finish the Observatory
Your leaky random forest exposed how target leakage can produce a convincing test score from illegitimate predictors. The observatory now needs models that learn only from physical transit measurements.
You will evaluate an audited forest plus a logistic regression baseline on the existing holdout split. You will then turn their results into a finished dashboard that communicates both performance and limitations.
In this step, get ready to:
- Compare two audited classifiers using the same physical features and test split.
- Rank unresolved candidates with a forest fitted after evaluation.
- Finish the interactive observatory with evaluation charts and scientific warnings.
Retrain with audited features
A fair comparison keeps the existing stratified holdout fixed. Each audited model receives the same 12 physical features from the same training rows.
Why compare two model types?
The forest can learn nonlinear interactions between transit measurements. Logistic regression provides a scaled linear baseline.
Similar results from different model families provide more context than a single score. The shared split keeps the comparison focused on model behavior.
- In curator.py, replace the existing scikit-learn import group with this completed group:
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix, roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
What do these imports add?
- LogisticRegression supplies the linear baseline used for comparison.
- StandardScaler puts physical measurements with different units onto comparable scales before logistic regression fits them.
- Find make_forest() in curator.py.
- Add the logistic pipeline directly below it using this code:
def make_logistic():
return Pipeline(
[
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
("model", LogisticRegression(max_iter=2000, class_weight="balanced")),
]
)
What does this pipeline do?
- The imputer replaces missing numeric measurements with each feature's median.
- The scaler prevents large-unit measurements from dominating the linear model.
- The balanced class weights account for unequal numbers of confirmed objects and false positives.
- Save curator.py.
- Check that the new pipeline has valid syntax by running:
python curator.py
What should this run prove?
You should see the dashboard build finish without a traceback. This confirms that the new imports and pipeline are valid inside the existing script.
Seeing an import or syntax error?
Check that both new imports sit with the existing scikit-learn imports. Confirm that make_logistic() begins at the left edge of the file.
Use this prompt if the script still stops: Help me diagnose the import or indentation error in my make_logistic function.
The audited evaluation now removes every disposition-derived field. Its ROC-AUC measures ranking performance on the untouched test rows.
- Inside build_dashboard(), find the line that calculates leaky_auc.
- Add the audited forest and logistic evaluation directly below that line using this code:
audited_forest = make_forest()
audited_forest.fit(labeled.loc[train_index, PHYSICAL_FEATURES], y_train)
forest_probability = audited_forest.predict_proba(
labeled.loc[test_index, PHYSICAL_FEATURES]
)[:, 1]
forest_prediction = audited_forest.predict(
labeled.loc[test_index, PHYSICAL_FEATURES]
)
forest_auc = roc_auc_score(y_test, forest_probability)
audited_confusion = confusion_matrix(y_test, forest_prediction, labels=[0, 1])
logistic = make_logistic()
logistic.fit(labeled.loc[train_index, PHYSICAL_FEATURES], y_train)
logistic_probability = logistic.predict_proba(
labeled.loc[test_index, PHYSICAL_FEATURES]
)[:, 1]
logistic_auc = roc_auc_score(y_test, logistic_probability)
How does the audit stay fair?
- Both pipelines train with train_index and evaluate with test_index.
- Both pipelines receive only PHYSICAL_FEATURES.
- The confusion matrix counts the audited forest's correct and incorrect classifications on the held-out rows.
- Save curator.py.
- Confirm that both audited models fit successfully by running:
python curator.py
What should this run prove?
You should see the dashboard build complete without a model-fitting error. The successful run proves that both audited pipelines can handle the physical measurements and their missing values.
Does model fitting stop?
Confirm that every audited fit() and predict_proba() call uses PHYSICAL_FEATURES. Check that the target remains y_train for training.
Use this prompt for focused help: Help me find why my audited scikit-learn pipelines fail on the shared train and test split.
Rank unresolved candidates
The holdout evaluation must finish before candidate ranking begins. A new forest can then train on every labeled row because its score is no longer used as test evidence.
Candidate probabilities remain educational rankings. They do not validate a signal or change its archive disposition.
- Inside build_dashboard(), add the final forest and physical feature-importance calculation below logistic_auc using this code:
final_forest = make_forest()
final_forest.fit(labeled[PHYSICAL_FEATURES], labeled["target"])
candidates["planet_likeness"] = final_forest.predict_proba(
candidates[PHYSICAL_FEATURES]
)[:, 1]
importance = pd.DataFrame(
{
"feature": PHYSICAL_FEATURES,
"importance": final_forest.named_steps["model"].feature_importances_,
}
)
importance["feature"] = importance["feature"].map(FEATURE_LABELS)
importance = importance.sort_values("importance", ascending=True)
What does the final forest produce?
- The fitted pipeline assigns each unresolved row a planet_likeness probability using physical features only.
- The underlying forest exposes impurity-based feature importances through named_steps.
- The label mapping turns archive column names into readable chart labels.
- Save curator.py.
- Confirm that candidate scoring completes by running:
python curator.py
What should this run prove?
You should see the build finish without a missing-feature error. This proves that the labeled rows and unresolved candidates share the 12 physical inputs required by the final forest.
Seeing a candidate-scoring error?
Check that both final-forest calls use PHYSICAL_FEATURES. Confirm that candidates still comes from rows whose disposition is CANDIDATE.
Use this prompt if the columns do not align: Help me diagnose the physical-feature mismatch in my final candidate-scoring forest.
Complete and verify the observatory
The final presentation layer turns model outputs into an interactive scientific story. Plotly supplies the score comparison, error heatmap, importance bars, candidate map, and self-contained browser interactions.
The page also states what the models cannot prove. This boundary keeps an attractive dashboard from overstating an educational ranking.
- Compare your curator.py file with the complete reference below.
- Add the missing model-score and evaluation figures from the reference.
- Add the candidate map and top-candidate table from the reference.
- Update page_template with the responsive space-themed presentation from the reference.
- Update the template substitutions and terminal summary from the reference.
✔️ Awesome, I've got everything!
Save curator.py once every final dashboard section is in place.
ⓧ I'd like to double check the full code
Use this complete file as a comparison reference. Every import, identifier, model input, chart, warning, and template substitution should match.
from datetime import datetime, timezone
from pathlib import Path
from string import Template
from urllib.parse import urlencode
import pandas as pd
import plotly.express as px
from plotly.io import to_html
from sklearn.ensemble import RandomForestClassifier
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix, roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
RANDOM_STATE = 42
TAP_ENDPOINT = "https://exoplanetarchive.ipac.caltech.edu/TAP/sync"
PHYSICAL_FEATURES = [
"koi_period",
"koi_impact",
"koi_duration",
"koi_depth",
"koi_prad",
"koi_teq",
"koi_insol",
"koi_model_snr",
"koi_steff",
"koi_slogg",
"koi_srad",
"koi_kepmag",
]
LEAKAGE_FEATURES = [
"koi_score",
"koi_fpflag_nt",
"koi_fpflag_ss",
"koi_fpflag_co",
"koi_fpflag_ec",
]
QUERY_COLUMNS = ["kepoi_name", "koi_disposition", *PHYSICAL_FEATURES, *LEAKAGE_FEATURES]
FEATURE_LABELS = {
"koi_period": "Orbital period",
"koi_impact": "Impact parameter",
"koi_duration": "Transit duration",
"koi_depth": "Transit depth",
"koi_prad": "Planetary radius",
"koi_teq": "Equilibrium temperature",
"koi_insol": "Insolation flux",
"koi_model_snr": "Transit signal-to-noise",
"koi_steff": "Stellar temperature",
"koi_slogg": "Stellar surface gravity",
"koi_srad": "Stellar radius",
"koi_kepmag": "Kepler-band magnitude",
}
DISPOSITION_COLORS = {
"CONFIRMED": "#78f7d4",
"CANDIDATE": "#a78bfa",
"FALSE POSITIVE": "#ff6b9d",
}
def fetch_data():
query = f"select {','.join(QUERY_COLUMNS)} from cumulative"
request_url = f"{TAP_ENDPOINT}?{urlencode({'query': query, 'format': 'csv'})}"
return pd.read_csv(request_url)
def make_forest():
return Pipeline(
[
("imputer", SimpleImputer(strategy="median")),
(
"model",
RandomForestClassifier(
n_estimators=350,
min_samples_leaf=4,
class_weight="balanced",
random_state=RANDOM_STATE,
n_jobs=-1,
),
),
]
)
def make_logistic():
return Pipeline(
[
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
("model", LogisticRegression(max_iter=2000, class_weight="balanced")),
]
)
def chart_html(figure, include_library=False):
return to_html(
figure,
full_html=False,
include_plotlyjs=include_library,
config={"displaylogo": False, "responsive": True},
)
def build_dashboard(data):
labeled = data[
data["koi_disposition"].isin(["CONFIRMED", "FALSE POSITIVE"])
].copy()
labeled["target"] = (labeled["koi_disposition"] == "CONFIRMED").astype(int)
candidates = data[data["koi_disposition"] == "CANDIDATE"].copy()
train_index, test_index = train_test_split(
labeled.index,
test_size=0.25,
random_state=RANDOM_STATE,
stratify=labeled["target"],
)
y_train = labeled.loc[train_index, "target"]
y_test = labeled.loc[test_index, "target"]
leaky_features = [*PHYSICAL_FEATURES, *LEAKAGE_FEATURES]
leaky_forest = make_forest()
leaky_forest.fit(labeled.loc[train_index, leaky_features], y_train)
leaky_probability = leaky_forest.predict_proba(
labeled.loc[test_index, leaky_features]
)[:, 1]
leaky_auc = roc_auc_score(y_test, leaky_probability)
audited_forest = make_forest()
audited_forest.fit(labeled.loc[train_index, PHYSICAL_FEATURES], y_train)
forest_probability = audited_forest.predict_proba(
labeled.loc[test_index, PHYSICAL_FEATURES]
)[:, 1]
forest_prediction = audited_forest.predict(
labeled.loc[test_index, PHYSICAL_FEATURES]
)
forest_auc = roc_auc_score(y_test, forest_probability)
audited_confusion = confusion_matrix(y_test, forest_prediction, labels=[0, 1])
logistic = make_logistic()
logistic.fit(labeled.loc[train_index, PHYSICAL_FEATURES], y_train)
logistic_probability = logistic.predict_proba(
labeled.loc[test_index, PHYSICAL_FEATURES]
)[:, 1]
logistic_auc = roc_auc_score(y_test, logistic_probability)
final_forest = make_forest()
final_forest.fit(labeled[PHYSICAL_FEATURES], labeled["target"])
candidates["planet_likeness"] = final_forest.predict_proba(
candidates[PHYSICAL_FEATURES]
)[:, 1]
importance = pd.DataFrame(
{
"feature": PHYSICAL_FEATURES,
"importance": final_forest.named_steps["model"].feature_importances_,
}
)
importance["feature"] = importance["feature"].map(FEATURE_LABELS)
importance = importance.sort_values("importance", ascending=True)
plot_data = data[(data["koi_period"] > 0) & (data["koi_prad"] > 0)].copy()
plot_data["signal_size"] = plot_data["koi_model_snr"].clip(lower=0).fillna(0) + 1
candidates["signal_size"] = (
candidates["koi_model_snr"].clip(lower=0).fillna(0) + 1
)
population_figure = px.scatter(
plot_data,
x="koi_period",
y="koi_prad",
color="koi_disposition",
size="signal_size",
hover_name="kepoi_name",
hover_data=["koi_duration", "koi_depth", "koi_teq", "koi_model_snr"],
color_discrete_map=DISPOSITION_COLORS,
size_max=18,
log_x=True,
log_y=True,
opacity=0.72,
labels={
"koi_period": "Orbital period in days",
"koi_prad": "Planetary radius in Earth radii",
"koi_disposition": "Archive disposition",
},
title="The Kepler candidate population",
template="plotly_dark",
height=590,
)
model_scores = pd.DataFrame(
[
{"model": "Leaky forest", "roc_auc": leaky_auc, "kind": "Do not trust"},
{"model": "Audited forest", "roc_auc": forest_auc, "kind": "Audited"},
{"model": "Logistic baseline", "roc_auc": logistic_auc, "kind": "Audited"},
]
)
score_figure = px.bar(
model_scores,
x="model",
y="roc_auc",
color="kind",
text_auto=".3f",
color_discrete_map={"Do not trust": "#ff6b9d", "Audited": "#78f7d4"},
range_y=[0.5, 1.0],
labels={"model": "", "roc_auc": "Test ROC-AUC", "kind": "Feature audit"},
title="Performance before and after the feature audit",
template="plotly_dark",
height=440,
)
confusion_figure = px.imshow(
audited_confusion,
x=["Predicted false positive", "Predicted confirmed"],
y=["Actual false positive", "Actual confirmed"],
labels={"x": "Prediction", "y": "Actual class", "color": "Count"},
text_auto=True,
color_continuous_scale=["#17132f", "#7c3aed", "#78f7d4"],
title="Audited random-forest confusion matrix",
template="plotly_dark",
aspect="auto",
height=440,
)
importance_figure = px.bar(
importance,
x="importance",
y="feature",
orientation="h",
color="importance",
text_auto=".3f",
color_continuous_scale=["#6d5dfc", "#d946ef", "#78f7d4"],
labels={"importance": "Impurity-based importance", "feature": ""},
title="What the audited forest uses",
template="plotly_dark",
height=520,
)
candidate_plot = candidates[
(candidates["koi_period"] > 0) & (candidates["koi_prad"] > 0)
].copy()
candidate_figure = px.scatter(
candidate_plot,
x="koi_period",
y="koi_prad",
color="planet_likeness",
size="signal_size",
hover_name="kepoi_name",
hover_data=["koi_teq", "koi_insol", "koi_model_snr"],
color_continuous_scale=["#362c76", "#a78bfa", "#78f7d4"],
range_color=[0, 1],
size_max=22,
log_x=True,
log_y=True,
opacity=0.82,
labels={
"koi_period": "Orbital period in days",
"koi_prad": "Planetary radius in Earth radii",
"planet_likeness": "Model probability",
},
title="Unresolved candidates through the audited model",
template="plotly_dark",
height=560,
)
top_candidates = candidates.sort_values(
"planet_likeness", ascending=False
).head(12)
candidate_table = top_candidates[
[
"kepoi_name",
"planet_likeness",
"koi_period",
"koi_prad",
"koi_teq",
"koi_model_snr",
]
].rename(
columns={
"kepoi_name": "KOI",
"planet_likeness": "Planet likeness",
"koi_period": "Period days",
"koi_prad": "Radius Earths",
"koi_teq": "Temperature K",
"koi_model_snr": "Transit SNR",
}
)
candidate_table = candidate_table.round(
{
"Period days": 2,
"Radius Earths": 2,
"Temperature K": 0,
"Transit SNR": 1,
}
)
candidate_table["Planet likeness"] = candidate_table["Planet likeness"].map(
lambda value: f"{value:.1%}"
)
table_html = candidate_table.to_html(
index=False,
classes="candidate-table",
border=0,
na_rep="Unknown",
)
counts = data["koi_disposition"].value_counts()
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
page_template = Template(
"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cosmic Curator | Exoplanet Signal Lab</title>
<style>
:root {
color-scheme: dark;
--ink: #f7f5ff;
--muted: #aaa6c9;
--violet: #8b5cf6;
--pink: #ec4899;
--mint: #78f7d4;
--panel: rgba(19, 15, 43, 0.74);
--line: rgba(196, 181, 253, 0.18);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background:
radial-gradient(circle at 12% 8%, rgba(124, 58, 237, 0.34), transparent 28rem),
radial-gradient(circle at 88% 18%, rgba(236, 72, 153, 0.23), transparent 24rem),
radial-gradient(circle at 54% 88%, rgba(45, 212, 191, 0.16), transparent 28rem),
#070611;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.42;
background-image:
radial-gradient(circle, rgba(255,255,255,.9) 0 1px, transparent 1.5px),
radial-gradient(circle, rgba(167,139,250,.8) 0 1px, transparent 1.5px);
background-position: 0 0, 36px 42px;
background-size: 92px 92px, 137px 137px;
mask-image: linear-gradient(to bottom, black, transparent 82%);
}
a { color: var(--mint); }
.shell { position: relative; width: min(1180px, 92vw); margin: 0 auto; padding: 64px 0 90px; }
.hero { padding: 62px 0 38px; }
.eyebrow {
display: inline-flex;
gap: 10px;
align-items: center;
padding: 8px 13px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--mint);
background: rgba(120,247,212,.07);
letter-spacing: .16em;
text-transform: uppercase;
font-size: .76rem;
font-weight: 750;
}
h1 {
max-width: 900px;
margin: 24px 0 16px;
font-size: clamp(3.2rem, 8vw, 7.2rem);
line-height: .88;
letter-spacing: -.065em;
background: linear-gradient(115deg, #fff 8%, #c4b5fd 47%, #78f7d4 92%);
-webkit-background-clip: text;
color: transparent;
}
.lede { max-width: 760px; color: #cbc7e6; font-size: clamp(1.05rem, 2vw, 1.35rem); line-height: 1.7; }
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin: 34px 0; }
.stat, .card {
border: 1px solid var(--line);
background: linear-gradient(145deg, rgba(32,25,69,.84), var(--panel));
box-shadow: 0 22px 70px rgba(0,0,0,.28), inset 0 1px rgba(255,255,255,.04);
backdrop-filter: blur(18px);
}
.stat { padding: 20px; border-radius: 22px; }
.stat strong { display: block; font-size: 1.75rem; letter-spacing: -.04em; }
.stat span { color: var(--muted); font-size: .82rem; text-transform: uppercase; letter-spacing: .1em; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.card { border-radius: 28px; overflow: hidden; padding: 18px; margin: 18px 0; }
.card.wide { grid-column: 1 / -1; }
.section-copy { padding: 14px 14px 2px; }
.section-copy h2 { margin: 0 0 8px; font-size: clamp(1.55rem, 3vw, 2.25rem); letter-spacing: -.035em; }
.section-copy p { color: var(--muted); line-height: 1.65; margin: 0; }
.warning {
margin: 18px 0;
padding: 22px;
border-radius: 22px;
border: 1px solid rgba(255,107,157,.34);
background: linear-gradient(135deg, rgba(255,107,157,.14), rgba(124,58,237,.1));
}
.warning strong { color: #ff9bbd; }
.table-wrap { overflow-x: auto; padding: 10px; }
.candidate-table { width: 100%; border-collapse: collapse; min-width: 720px; }
.candidate-table th, .candidate-table td { padding: 14px 12px; border-bottom: 1px solid var(--line); text-align: left; }
.candidate-table th { color: var(--mint); font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; }
.candidate-table tbody tr:hover { background: rgba(167,139,250,.08); }
.footer { color: var(--muted); padding-top: 28px; line-height: 1.7; }
.pill { color: #d8d4ef; background: rgba(167,139,250,.1); border: 1px solid var(--line); border-radius: 999px; padding: 4px 9px; white-space: nowrap; }
@media (max-width: 860px) {
.stats { grid-template-columns: 1fr 1fr; }
.grid { grid-template-columns: 1fr; }
.card.wide { grid-column: auto; }
}
@media (max-width: 520px) {
.stats { grid-template-columns: 1fr; }
.shell { width: min(94vw, 1180px); padding-top: 24px; }
.card { padding: 8px; border-radius: 20px; }
}
</style>
</head>
<body>
<main class="shell">
<header class="hero">
<span class="eyebrow">NASA data · honest ML · interactive story</span>
<h1>Cosmic Curator</h1>
<p class="lede">An exoplanet signal lab that turns Kepler transit measurements into an interactive lesson about classification, target leakage, and responsible model interpretation.</p>
<div class="stats">
<div class="stat"><strong>$total_rows</strong><span>KOIs retrieved</span></div>
<div class="stat"><strong>$confirmed_rows</strong><span>Confirmed</span></div>
<div class="stat"><strong>$candidate_rows</strong><span>Candidates</span></div>
<div class="stat"><strong>$false_positive_rows</strong><span>False positives</span></div>
</div>
</header>
<section class="card wide">
<div class="section-copy"><h2>A sky full of signals</h2><p>Use the legend, hover labels, zoom, and box selection to explore how transit period, estimated radius, and signal-to-noise vary across archive dispositions.</p></div>
$population_chart
</section>
<section class="warning">
<strong>The trap:</strong> `koi_score` and the four `koi_fpflag_*` fields summarize or contribute to Kepler's existing disposition process. A model trained with them can reproduce the answer it is supposed to predict. Cosmic Curator shows that result, then removes those fields.
</section>
<div class="grid">
<section class="card">
<div class="section-copy"><h2>Too good, then audited</h2><p>The honest comparison uses one held-out split for every model. The score usually falls after leakage is removed, and that is a feature of the audit rather than a failure.</p></div>
$score_chart
</section>
<section class="card">
<div class="section-copy"><h2>Where the forest misses</h2><p>Counts come only from the held-out test set of confirmed and false-positive objects.</p></div>
$confusion_chart
</section>
<section class="card wide">
<div class="section-copy"><h2>Signals the forest follows</h2><p>These impurity-based importances describe this fitted forest. They do not establish scientific causation.</p></div>
$importance_chart
</section>
<section class="card wide">
<div class="section-copy"><h2>The unresolved frontier</h2><p>Color is the audited forest's probability for the learned <span class="pill">CONFIRMED</span> class. Treat it as an educational ranking, not validation.</p></div>
$candidate_chart
</section>
</div>
<section class="card wide">
<div class="section-copy"><h2>Highest-ranked unresolved candidates</h2><p>The model has never been given archive disposition scores or false-positive flags for this ranking.</p></div>
<div class="table-wrap">$candidate_table</div>
</section>
<section class="warning">
<strong>Scientific limitation:</strong> This page does not discover, validate, or confirm exoplanets. Archive labels can change, catalog measurements have uncertainty, candidate rows are not ground-truth positives, and this short project does not perform external validation or probability calibration.
</section>
<footer class="footer">
Generated $generated_at from the NASA Exoplanet Archive KOI cumulative table. Test ROC-AUC: leaky forest $leaky_auc, audited forest $forest_auc, logistic baseline $logistic_auc.
</footer>
</main>
</body>
</html>"""
)
page = page_template.substitute(
total_rows=f"{len(data):,}",
confirmed_rows=f"{counts.get('CONFIRMED', 0):,}",
candidate_rows=f"{counts.get('CANDIDATE', 0):,}",
false_positive_rows=f"{counts.get('FALSE POSITIVE', 0):,}",
population_chart=chart_html(population_figure, include_library=True),
score_chart=chart_html(score_figure),
confusion_chart=chart_html(confusion_figure),
importance_chart=chart_html(importance_figure),
candidate_chart=chart_html(candidate_figure),
candidate_table=table_html,
generated_at=generated_at,
leaky_auc=f"{leaky_auc:.3f}",
forest_auc=f"{forest_auc:.3f}",
logistic_auc=f"{logistic_auc:.3f}",
)
output_directory = Path("docs")
output_directory.mkdir(exist_ok=True)
output_path = output_directory / "index.html"
output_path.write_text(page, encoding="utf-8")
return {
"rows": len(data),
"leaky_auc": leaky_auc,
"forest_auc": forest_auc,
"logistic_auc": logistic_auc,
"output": output_path,
}
def main():
results = build_dashboard(fetch_data())
print(f"Built {results['output']} from {results['rows']:,} KOIs")
print(f"Leaky forest ROC-AUC: {results['leaky_auc']:.3f}")
print(f"Audited forest ROC-AUC: {results['forest_auc']:.3f}")
print(f"Logistic baseline ROC-AUC: {results['logistic_auc']:.3f}")
if __name__ == "__main__":
main()
How should I use this reference?
Compare the file section by section. Pay particular attention to the feature lists passed into each model and the names used by page_template.substitute().
The final forest may use every labeled row only after the held-out evaluation finishes. Every candidate score must still use PHYSICAL_FEATURES.
Before you run the finished script, which score do you expect to be strongest after the leaky fields are included?
- Save the completed curator.py file.
- Build the finished observatory by running:
python curator.py
What should the terminal report?
You should see confirmation that docs/index.html was built from the retrieved KOIs. Three separate ROC-AUC lines should follow for the leaky forest, audited forest, and logistic baseline.
The leaky result is usually the strongest. The dashboard labels it as untrustworthy because its feature set contains the existing vetting decision.
- Switch back to the browser tab showing docs/index.html from earlier.
- Refresh the page.
- Hover over points in the unresolved-candidate map.
- Inspect the highest-ranked rows in the candidate table.
You should see three model scores, an audited confusion matrix, physical feature-importance bars, a colored candidate map, and a top-12 table. The footer should show a UTC generation timestamp.
The finished page should also display two clear boundaries. One warning identifies the leakage fields. The other states that the candidate ranking does not discover, validate, or confirm exoplanets.
Missing a chart or table?
Confirm that each chart variable passed into page_template.substitute() has a matching placeholder in the HTML template. Check the terminal for the first traceback before refreshing again.
If the page looks unstyled, confirm that the entire style section remains inside page_template.
Use this prompt for targeted debugging: Help me trace why a finished Cosmic Curator chart, table, or template value is missing from docs/index.html.
Your local observatory now tells the complete modeling story. Next, you will publish it so other people can explore the same dashboard from a public URL.
Publish the Refreshed Site
Your finished observatory already compares the leaky model with two audited models. It also turns current NASA Exoplanet Archive data into a self-contained dashboard.
Publishing with GitHub Pages gives that dashboard a public address. GitHub Actions rebuilds the page from fresh data without relying on your laptop.
In this step, get ready to:
- Publish the local project in a public repository.
- Configure the automated build workflow.
- Verify the dashboard from a completed deployment.
Create and push the public repository
A Git repository records each version of your project. GitHub hosts that history so the publishing workflow can access it.
A public repository makes your project files visible to anyone. The repository contains no API secret.
- Open GitHub's New repository page in your browser.
- Select your personal account from the Owner menu.
- Enter cosmic-curator in the Repository name field.
- Choose Public in the visibility area.
- Leave every repository initialization option unselected.
- Click Create repository.
GitHub opens the empty repository's Quick Setup page. This page provides the remote address for your local project.
- Copy the HTTPS remote URL from the Quick Setup page.
- Store the copied address here: https://github.com/OWNER/cosmic-curator.git.
- Switch back to VS Code from earlier.
- Select .github/workflows/pages.yml in the Explorer sidebar.
- Delete the placeholder sentence from .github/workflows/pages.yml.
- Save .github/workflows/pages.yml.
The workflow file is now empty. Git leaves empty files out of a commit.
- Switch back to the activated terminal from earlier.
- Create the local repository history by running these commands:
git init
git add .
git commit -m 'message'
git branch -M main
What do these commands do?
- The git init command starts version history inside cosmic-curator.
- The git add . command stages the project files while respecting .gitignore.
- The git commit -m 'message' command stores the first snapshot.
- The git branch -M main command names the publishing branch main.
- Confirm the terminal reports a new commit containing curator.py plus the generated docs/index.html file.
Commit did not complete?
Git may ask you to configure an author name or email before it creates the commit. Follow the instructions printed in your terminal.
Make sure the terminal still points to the cosmic-curator folder.
Help me diagnose the failed first Git commit.
Git may open a browser so you can authenticate with GitHub during the first push. This grants Git permission to upload the commit.
Before you push, do you expect the ignored .venv folder to appear in the public repository?
- Connect the local repository to GitHub by running these commands:
git remote add origin [[YOUR_REPO_URL="https://github.com/OWNER/cosmic-curator.git"]]
git remote -v
git push -u origin main
What does this upload?
The first command stores your repository URL under the name origin. The second command prints the saved fetch address plus the saved push address.
The final command uploads main to GitHub. It also connects future local pushes to that remote branch.
- Refresh the public cosmic-curator repository in your browser.
- Confirm the repository lists requirements.txt plus .gitignore plus curator.py plus docs.
- Confirm the repository does not list .venv.
Your source code is now public. The isolated environment stayed safely on your computer.
Push not reaching GitHub?
Confirm the URL printed by the remote check matches the empty repository you created. Complete any GitHub authentication request before retrying the push.
Help me troubleshoot the GitHub push.
Configure the Pages workflow
GitHub Pages needs a publishing source before it can expose the dashboard. The GitHub Actions option lets your workflow build the site before handing the docs artifact to Pages.
- Return to the public cosmic-curator repository.
- Select the Settings tab.
- Select Pages in the sidebar.
- Choose GitHub Actions from the Source menu under Build and deployment.
That publishing route is now ready. Your next push can provide the generated dashboard as a Pages artifact.
The workflow has three sections. You will keep the file local until every section is present.
- Switch back to VS Code from earlier.
- Select .github/workflows/pages.yml in the Explorer sidebar.
- Add the workflow triggers plus permissions by pasting this first section:
name: Refresh and publish Cosmic Curator
on:
push:
branches:
- main
workflow_dispatch:
schedule:
- cron: "30 5 * * 1-5"
timezone: "America/New_York"
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
What controls the refresh?
The workflow starts after pushes to main. The workflow_dispatch trigger also supports manual runs.
The 30 5 * * 1-5 schedule requests weekday refreshes in the America/New_York timezone. The permissions allow the workflow to publish through Pages.
- Save .github/workflows/pages.yml.
- Confirm push plus workflow_dispatch plus schedule align beneath on.
Triggers look misaligned?
YAML uses indentation to represent structure. Match the spaces in the reference exactly.
Help me check the trigger indentation.
The build job creates a clean Linux environment. It prepares Python 3.12 before running the same dashboard command you used locally.
- Append the build job below cancel-in-progress: false by pasting this section:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out the repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Configure GitHub Pages
uses: actions/configure-pages@v5
- name: Install dependencies
run: python -m pip install -r requirements.txt
- name: Build the dashboard
run: python curator.py
- name: Upload the static site
uses: actions/upload-pages-artifact@v4
with:
path: docs
How does the build work?
The build checks out the repository. It sets up Python 3.12 before installing the pinned packages from requirements.txt.
The workflow runs python curator.py to retrieve current data. It uploads the resulting docs folder as the static site artifact.
- Save .github/workflows/pages.yml.
- Confirm the build job contains six named steps.
Build steps nested incorrectly?
Keep every dash beneath steps at the same indentation level. Keep each uses or run line beneath its matching name.
Help me check the build job structure.
The deployment job receives the artifact after the build succeeds. Its environment records the public Pages URL from the deployment output.
- Append the deployment job beneath the build job by pasting this final section:
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
How does deployment follow the build?
The needs: build setting makes deployment wait for a successful build. The github-pages environment receives the uploaded static artifact.
The deployment step exposes its public address through steps.deployment.outputs.page_url.
- Save .github/workflows/pages.yml.
- Confirm the deploy job aligns beneath the build job.
Deploy job inside the build job?
Align deploy with build beneath jobs. The needs line belongs inside deploy.
Help me check the deployment job indentation.
✔️ Awesome, I've got everything!
All three sections are in place. Your local workflow is ready for one final comparison.
ⓧ I'd like to double check the full code
name: Refresh and publish Cosmic Curator
on:
push:
branches:
- main
workflow_dispatch:
schedule:
- cron: "30 5 * * 1-5"
timezone: "America/New_York"
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out the repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Configure GitHub Pages
uses: actions/configure-pages@v5
- name: Install dependencies
run: python -m pip install -r requirements.txt
- name: Build the dashboard
run: python curator.py
- name: Upload the static site
uses: actions/upload-pages-artifact@v4
with:
path: docs
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
Run and verify the deployment
The first push of this workflow tests the complete publishing path. GitHub must install the dependencies before it can rebuild the current dashboard.
Before you push, do you expect the workflow to start automatically from this commit?
- Switch back to the activated terminal from earlier.
- Publish the completed workflow by running these commands:
git add .
git commit -m 'message'
git push origin main
What happens after the push?
Git stores the workflow in a new commit. The push places that commit on the remote main branch.
The push trigger starts Refresh and publish Cosmic Curator. The build retrieves current archive data before regenerating docs/index.html.
- Confirm the terminal reports that main was pushed to origin.
Workflow push rejected?
Confirm your local branch is named main. Confirm the saved remote still points to your public repository.
Help me troubleshoot the workflow push.
The first build can take a few minutes while GitHub prepares Python packages. Each job shows its progress while you wait.
- Return to the public cosmic-curator repository.
- Select the Actions tab.
- Open Refresh and publish Cosmic Curator.
- Select the newest workflow run.
- Wait for the build job to complete successfully.
- Wait for the deployment job to complete successfully.
- Select the deployment job.
The completed job shows the Pages URL for the github-pages environment. This proves the artifact reached the publishing environment.
Before you open the URL, do you expect to see repository files or the rendered Cosmic Curator dashboard?
- Open the Pages URL shown by the completed deployment.
- Confirm the page displays the Cosmic Curator title.
- Confirm the page displays the population map plus all three model scores.
- Confirm the footer displays a current UTC generation timestamp.
You did it. Cosmic Curator now has a public address backed by a reproducible data build.
How does the site stay refreshed?
Every push to main starts another deployment. The workflow_dispatch trigger also lets you start one manually.
The 30 5 * * 1-5 schedule requests weekday rebuilds in the America/New_York timezone. Each rebuild fetches current NASA Exoplanet Archive data without an API secret.
Secret mission
Find Where the Models Disagree
Similar evaluation scores can hide very different predictions for individual candidates. Build a separate interactive view that reveals where the audited linear and tree-based models disagree most.
Clean Up Your Resources
Clean Up Your Resources
Keeping this project costs $0 while its public GitHub repository remains public.
Choose whether to keep the automated site live, pause weekday refreshes, or remove the project completely.
Resources you used:
- Local files: The cosmic-curator folder with its .venv environment plus generated pages.
- Hosted resources: The public GitHub repository cosmic-curator with its GitHub Actions workflow plus GitHub Pages deployment.
Keep everything running
No action needed. Choose this if you are still actively building or want the scheduled refreshes to continue.
- Leave the public cosmic-curator repository unchanged.
- Keep the weekday workflow schedule unchanged.
- Keep the local cosmic-curator folder for future model experiments.
- Bookmark the published Pages URLs for the main dashboard plus the model-disagreement view.
Pause - I'll come back to this later
This option stops automated weekday builds. Your local files plus published site stay available.
- In VS Code, select .github/workflows/pages.yml from the file sidebar.
- Find the schedule: block under on:.
- Remove the schedule: block containing cron: plus timezone:.
- Save .github/workflows/pages.yml.
- Commit the workflow change on main using the Git process from Step 5.
- Push main to origin using the same process.
- Return to GitHub Actions after the push finishes.
- Confirm the weekday schedule no longer appears as a workflow trigger.
- Open the existing GitHub Pages URL to confirm the dashboard still loads.
The dashboard stays live.
Push-triggered builds remain available. Manual runs remain available.
Delete - I don't want to use this again
This option removes the hosted project plus the local workspace.
Repository deletion is permanent. Your files remain recoverable only if you copy them somewhere else first.
- Copy any files you want to keep out of the existing cosmic-curator folder.
- Return to the cosmic-curator repository on GitHub.
- Select Settings.
- Locate the repository deletion controls at the bottom of the settings page.
- Choose the option that permanently deletes the cosmic-curator repository.
- Enter cosmic-curator if the confirmation prompt requests the repository name.
- Confirm the permanent deletion.
That clears the hosted half of the project. The Pages site plus its workflow can no longer run.
- Press Cmd+Space to open Spotlight.
- Type Finder into Spotlight.
- Press Enter to open Finder.
- Locate the existing cosmic-curator folder.
- Move the cosmic-curator folder to the Trash.
- Empty your Mac's Trash.
- Confirm the cosmic-curator folder no longer appears in Finder.
Nice Work!
Nice Work!
You did it! Cosmic Curator is now live on GitHub Pages as an interactive exoplanet signal lab for honest model evaluation.
You've learned how to:
- Built a keyless TAP data pipeline that retrieves current data from the NASA Exoplanet Archive. Turned the Kepler population into an interactive log-scale map with detailed hover labels.
- Exposed deliberate target leakage from disposition-derived fields. Compared an audited random forest with a scaled logistic regression baseline using ROC-AUC. Inspected classification errors through a confusion matrix.
- Ranked unresolved candidates using physical transit and stellar measurements only. Automated dashboard refreshes with GitHub Actions across every documented trigger.
- Secret Mission: Built a separate Plotly view for audited-model disagreement. Highlighted candidates where forest probability diverges most from logistic probability. Interpreted those gaps as model uncertainty. Treated ground truth as unresolved.
Ready to quiz yourself?