Gareth Grant · Applied machine learning portfolio · 4 October 2026
How can a reproducible classifier help organise stellar observations, and what evidence is needed before anyone relies on it?
This project classifies the six labels in the supplied Stars.csv using four physical measurements. It combines data auditing, visual exploration, Naive Bayes, KNN, an interpretable decision tree, and honest evaluation. The important outcome is a traceable workflow and an explanation of its limits.
Scope: a small educational dataset, not a validated observatory product. A strong score on this file establishes performance on this file's held-out sample. It does not establish accuracy on a new telescope survey.
How to use this notebook
- Put this notebook beside
Stars.csv, install the packages inrequirements.txt, and select a Python kernel. - Restart the kernel and run the cells from top to bottom. Outputs are already included for readers.
- Each code cell does one task. Read its explanation, run it, and add your own comment at
# Your note:. - Treat the final test set as locked. If you change the workflow after viewing its results, use fresh external data for a new final evaluation.
The companion HTML contains the same code, outputs, and interpretation for a portfolio website. Business and policy opportunities are proposals; measured model results and hypothetical savings are identified separately.
Reading route
| Section | What you learn |
|---|---|
| 1–3 | Reproducibility, data quality, and a locked train/test split |
| 4–5 | Physical interpretation, log scales, and model assumptions |
| 6–8 | Pipelines, training-only tuning, and nested cross-validation |
| 9–11 | Final test results, error analysis, and model explanation |
| 12 | Input checks and candidate predictions for expert review |
| 13–14 | Business, astronomy, government, and scientific limits |
| 15–17 | Reusable outputs, a website case study, and learning exercises |
1. Set up a reproducible analysis
An import makes a library available; it does not train a model. Imports are grouped by their purpose so a reader can see why each tool is here.
Step 01 · Import file and environment tools
These tools handle portable paths, checksums, saved metadata, and package versions.
Python · Cell 01View code
# Import file and environment tools.
from pathlib import Path
from importlib.metadata import version
import hashlib
import json
import platform
import joblib
# Your note:
Step 02 · Import data tools
pandas handles tables; numpy handles numerical arrays and logarithms.
Python · Cell 02View code
# Import data tools.
import numpy as np
import pandas as pd
# Your note:
Step 03 · Import plotting tools
Plots make scale differences and classification errors easier to inspect.
Python · Cell 03View code
# Import plotting tools.
import matplotlib.pyplot as plt
import seaborn as sns
# Your note:
Step 04 · Import scientific diagnostics
Shapiro–Wilk checks a normality assumption; a standard-normal quantile supplies a confidence-interval constant.
Python · Cell 04View code
# Import scientific diagnostics.
from scipy import stats
# Your note:
Step 05 · Import splitting and validation tools
A split holds back the final test; cross-validation evaluates choices inside training data.
Python · Cell 05View code
# Import splitting and validation tools.
from sklearn.model_selection import (
train_test_split, StratifiedKFold, GridSearchCV, cross_validate
)
# Your note:
Step 06 · Import preprocessing tools
Pipelines keep transformations attached to the classifier. See the official leakage guidance.
Python · Cell 06View code
# Import preprocessing tools.
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import FunctionTransformer, StandardScaler, label_binarize
# Your note:
Step 07 · Import classifiers
Keep the original Naive Bayes and KNN methods; add a no-skill benchmark and a readable tree.
Python · Cell 07View code
# Import classifiers.
from sklearn.dummy import DummyClassifier
from sklearn.naive_bayes import GaussianNB
from sklearn.neighbors import KNeighborsClassifier
from sklearn.tree import DecisionTreeClassifier, plot_tree, export_text
# Your note:
Step 08 · Import evaluation tools
Classification metrics belong here; regression metrics such as RMSE and R² do not answer this classification question.
Python · Cell 08View code
# Import evaluation tools.
from sklearn.metrics import (
accuracy_score, balanced_accuracy_score, precision_score, recall_score,
f1_score, classification_report, confusion_matrix,
ConfusionMatrixDisplay, roc_curve, roc_auc_score, log_loss
)
from sklearn.inspection import permutation_importance
# Your note:
Step 09 · Choose fixed run settings
Choose the split and evaluation plan before inspecting model test scores. Change the seed to study reproducibility, not to hunt for the best test result.
Python · Cell 09View code
# Choose fixed run settings.
RANDOM_SEED = 42
TEST_FRACTION = 0.20
CV_FOLDS = 5
N_JOBS = 1
OUTPUT_DIR = Path('outputs')
OUTPUT_DIR.mkdir(exist_ok=True)
# Your note:
Step 10 · Set clear display styles
Use readable numbers and restrained colours. Colour choices affect presentation, not model fitting.
Python · Cell 10View code
# Set clear display styles.
pd.set_option('display.max_columns', 12)
pd.set_option('display.max_rows', 30)
pd.set_option('display.precision', 4)
sns.set_theme(style='whitegrid', context='notebook')
plt.rcParams.update({'figure.dpi': 110, 'savefig.dpi': 180})
CLASS_COLORS = ['#667582', '#C7353F', '#247B73', '#16324F', '#AA752C', '#794F91']
# Your note:
Step 11 · Record the software environment
Version information makes differences between computers easier to investigate.
Python · Cell 11View code
# Record the software environment.
package_versions = {
name: version(name)
for name in ['numpy', 'pandas', 'scipy', 'scikit-learn', 'matplotlib', 'seaborn', 'joblib']
}
print('Python:', platform.python_version())
pd.Series(package_versions, name='version')
# Your note:
Python: 3.12.14
| version | |
|---|---|
| numpy | 2.3.5 |
| pandas | 2.2.3 |
| scipy | 1.17.0 |
| scikit-learn | 1.8.0 |
| matplotlib | 3.10.8 |
| seaborn | 0.13.2 |
| joblib | 1.5.3 |
2. Understand and audit the data
The original submission cites Deepu's Kaggle star dataset. This notebook uses the uploaded file as the authoritative input. Its extra Star category column supplies human-readable labels. The file has no source identifiers, observing dates, telescope identifiers, error bars, or documented selection probabilities. We cannot reconstruct its catalogue lineage or survey completeness from these columns.
Data dictionary
| Column | Meaning in this file | Role |
|---|---|---|
Temperature (K) |
Temperature in kelvin; an effective-temperature interpretation needs source confirmation | Numeric predictor |
Luminosity (L/Lo) |
Luminosity relative to the Sun; bolometric/passband definition needs confirmation | Numeric predictor |
Radius (R/Ro) |
Radius relative to the Sun | Numeric predictor |
Absolute magnitude (Mv) |
Labelled as visual absolute magnitude; smaller values mean brighter objects in that band | Numeric predictor |
Star type |
Integer class code, 0–5 | Target |
Star category |
Text version of the target | Display only; including it in predictors would leak the answer |
Star color |
Descriptive text with inconsistent spelling/case | Audited; not used in the core model |
Spectral Class |
Letter class | Audited; not used in the core model |
These are the dataset's six mutually exclusive labels. They are a simplified teaching taxonomy: in physical astronomy, red dwarfs are main-sequence stars, so these labels are not a universal, non-overlapping taxonomy. A model learns the supplied labels rather than resolving those definitions.
Step 12 · Locate the input file
A relative path works on Windows, macOS, and Linux when the CSV is beside the notebook. If necessary, edit just this cell.
Python · Cell 12View code
# Locate the input file.
DATA_PATH = Path('Stars.csv')
if not DATA_PATH.is_file():
raise FileNotFoundError('Place Stars.csv beside this notebook, or edit DATA_PATH.')
print('Dataset:', DATA_PATH.name)
# Your note:
Dataset: Stars.csv
Step 13 · Fingerprint the input
A SHA-256 checksum identifies the exact file used. It detects byte changes; it does not establish scientific provenance.
Python · Cell 13View code
# Fingerprint the input.
data_sha256 = hashlib.sha256(DATA_PATH.read_bytes()).hexdigest()
print('SHA-256:', data_sha256)
# Your note:
SHA-256: e26584e4be5dbec3a6aba1cca27e38ebc17df85ddc7a5c34ecd5244f92693f7e
Step 14 · Load the original data
Keep the loaded table unchanged; make a working copy for cleaning.
Python · Cell 14View code
# Load the original data.
raw_df = pd.read_csv(DATA_PATH)
# Your note:
Step 15 · Inspect the first five records
Look at the names, units, values, and target labels before selecting predictors.
Python · Cell 15View code
# Inspect the first five records.
raw_df.head()
# Your note:
| Temperature (K) | Luminosity (L/Lo) | Radius (R/Ro) | Absolute magnitude (Mv) | Star type | Star category | Star color | Spectral Class | |
|---|---|---|---|---|---|---|---|---|
| 0 | 3068 | 0.0024 | 0.1700 | 16.12 | 0 | Brown Dwarf | Red | M |
| 1 | 3042 | 0.0005 | 0.1542 | 16.60 | 0 | Brown Dwarf | Red | M |
| 2 | 2600 | 0.0003 | 0.1020 | 18.70 | 0 | Brown Dwarf | Red | M |
| 3 | 2800 | 0.0002 | 0.1600 | 16.65 | 0 | Brown Dwarf | Red | M |
| 4 | 1939 | 0.0001 | 0.1030 | 20.06 | 0 | Brown Dwarf | Red | M |
Step 16 · Count rows and columns
The unit here is a row described as a stellar record. Without source identifiers, unique physical objects cannot be verified.
Python · Cell 16View code
# Count rows and columns.
print(f'{raw_df.shape[0]} rows × {raw_df.shape[1]} columns')
# Your note:
240 rows × 8 columns
Step 17 · Inspect column types
Unexpected text in a measurement column would need attention before modelling.
Python · Cell 17View code
# Inspect column types.
raw_df.dtypes.rename('dtype')
# Your note:
| dtype | |
|---|---|
| Temperature (K) | int64 |
| Luminosity (L/Lo) | float64 |
| Radius (R/Ro) | float64 |
| Absolute magnitude (Mv) | float64 |
| Star type | int64 |
| Star category | object |
| Star color | object |
| Spectral Class | object |
Step 18 · Check missing values once
isna() and isnull() are aliases; repeating both does not add a second check.
How to read the output: Zero means no missing entries in that column. It does not mean the measurements are correct.
Python · Cell 18View code
# Check missing values once.
raw_df.isna().sum().rename('missing_count')
# Your note:
| missing_count | |
|---|---|
| Temperature (K) | 0 |
| Luminosity (L/Lo) | 0 |
| Radius (R/Ro) | 0 |
| Absolute magnitude (Mv) | 0 |
| Star type | 0 |
| Star category | 0 |
| Star color | 0 |
| Spectral Class | 0 |
Step 19 · Check duplicate records
Count exact duplicate rows without silently deleting them. Repeated observations would require source-aware grouping.
Python · Cell 19View code
# Check duplicate records.
print('Exact duplicate rows:', int(raw_df.duplicated().sum()))
# Your note:
Exact duplicate rows: 0
Step 20 · Define the class names
Use the mapping observed in the uploaded file, not an assumed ordering from another copy of the dataset.
Python · Cell 20View code
# Define the class names.
CLASS_NAMES = {
0: 'Brown Dwarf', 1: 'Red Dwarf', 2: 'White Dwarf',
3: 'Main Sequence', 4: 'Supergiant', 5: 'Hypergiant'
}
CLASS_IDS = list(CLASS_NAMES)
CLASS_LABELS = list(CLASS_NAMES.values())
# Your note:
Step 21 · Define the numeric predictors
An explicit allow-list makes accidental inclusion of the target easier to prevent.
Python · Cell 21View code
# Define the numeric predictors.
NUMERIC_FEATURES = [
'Temperature (K)', 'Luminosity (L/Lo)',
'Radius (R/Ro)', 'Absolute magnitude (Mv)'
]
POSITIVE_FEATURES = NUMERIC_FEATURES[:3]
# Your note:
Step 22 · Validate the input schema
Fail clearly if the required fields or class codes have changed.
Python · Cell 22View code
# Validate the input schema.
required_columns = set(NUMERIC_FEATURES + ['Star type', 'Star category', 'Star color', 'Spectral Class'])
assert required_columns.issubset(raw_df.columns), 'Required columns are missing.'
assert raw_df['Star type'].notna().all(), 'Missing target labels.'
assert set(raw_df['Star type']) == set(CLASS_IDS), 'Unexpected class codes.'
assert raw_df[NUMERIC_FEATURES].apply(pd.api.types.is_numeric_dtype).all(), 'Non-numeric predictors.'
print('Required columns and class codes are valid.')
# Your note:
Required columns and class codes are valid.
Step 23 · Verify every target-to-name mapping
Matching class counts alone is insufficient: check that each row has the correct text label.
Python · Cell 23View code
# Verify every target-to-name mapping.
expected_names = raw_df['Star type'].map(CLASS_NAMES)
label_mismatches = raw_df['Star category'].ne(expected_names)
assert not label_mismatches.any(), 'Star category disagrees with Star type.'
print('All numeric class codes match their text labels.')
# Your note:
All numeric class codes match their text labels.
Step 24 · Check numerical validity
Temperature, luminosity, and radius must be positive for logarithms. Absolute magnitude may legitimately be negative.
Python · Cell 24View code
# Check numerical validity.
assert np.isfinite(raw_df[NUMERIC_FEATURES].to_numpy()).all(), 'Missing or non-finite measurements.'
assert raw_df[POSITIVE_FEATURES].gt(0).all().all(), 'Non-positive physical measurements.'
print('Predictors are finite; log-transform inputs are positive.')
# Your note:
Predictors are finite; log-transform inputs are positive.
Step 25 · Create a working copy
Keeping the input intact helps compare cleaning choices and reproduce the audit.
Python · Cell 25View code
# Create a working copy.
df = raw_df.copy(deep=True)
# Your note:
Step 26 · Standardise colour spelling
This is deterministic text cleaning. No information is learned from validation or test records.
Python · Cell 26View code
# Standardise colour spelling.
df['Star color'] = (
df['Star color'].str.strip().str.lower()
.str.replace(r'[-\s]+', ' ', regex=True)
)
# Your note:
Step 27 · Standardise spectral text
Remove spaces and normalise case, while preserving the original file in raw_df.
Python · Cell 27View code
# Standardise spectral text.
df['Spectral Class'] = df['Spectral Class'].str.strip().str.upper()
# Your note:
Step 28 · Summarise the colour cleaning
Case, hyphen, and space variants can be merged; distinct descriptive terms are not automatically treated as synonyms.
Python · Cell 28View code
# Summarise the colour cleaning.
print('Original colour categories:', raw_df['Star color'].nunique())
print('Cleaned colour categories:', df['Star color'].nunique())
df['Star color'].value_counts().rename('records')
# Your note:
Original colour categories: 19 Cleaned colour categories: 12
| records | |
|---|---|
| Star color | |
| red | 112 |
| blue | 56 |
| blue white | 41 |
| white | 10 |
| yellow white | 8 |
| yellowish white | 3 |
| yellowish | 3 |
| whitish | 2 |
| orange | 2 |
| pale yellow orange | 1 |
| white yellow | 1 |
| orange red | 1 |
Step 29 · Summarise the class balance
A balanced teaching sample is useful for comparisons but does not measure how common these classes are in the sky.
Python · Cell 29View code
# Summarise the class balance.
class_counts = df['Star type'].value_counts().sort_index()
pd.DataFrame({'class': CLASS_LABELS, 'records': class_counts.to_numpy(),
'sample_share': class_counts.to_numpy() / len(df)})
# Your note:
| class | records | sample_share | |
|---|---|---|---|
| 0 | Brown Dwarf | 40 | 0.1667 |
| 1 | Red Dwarf | 40 | 0.1667 |
| 2 | White Dwarf | 40 | 0.1667 |
| 3 | Main Sequence | 40 | 0.1667 |
| 4 | Supergiant | 40 | 0.1667 |
| 5 | Hypergiant | 40 | 0.1667 |
Audit interpretation. The supplied file contains 240 rows, no missing entries, no exact duplicate rows, and 40 rows in each class. The text target agrees with the integer target row by row. Cleaning reduces spelling variants in colour descriptions. These checks establish internal consistency, not representativeness or observational accuracy.
Why use only four numeric predictors? This keeps the core tutorial focused and preserves the original model question. Colour and spectral class may be useful additional inputs, but their availability and how their labels were derived need scrutiny. They are not inherently unusable for machine learning. Nominal colour strings should not be turned into arbitrary ordinal numbers and treated as meaningful distances.
3. Lock the final test set before exploration
All learned preprocessing, model tuning, and model-family selection use training records. The final holdout is used once, after the family is selected. Basic schema and file-quality checks above do not choose predictive settings.
We use an 80/20 stratified split: 192 training records and 48 test records, with eight test records per class. The same records evaluate every model. The earlier submission's test fractions were different for the two methods and were chosen after looking at scores; that comparison cannot establish which algorithm is generally better.
Step 30 · Separate predictors from labels
Star category and Star type are excluded from X by construction.
Python · Cell 30View code
# Separate predictors from labels.
X = df[NUMERIC_FEATURES].copy()
y = df['Star type'].copy()
# Your note:
Step 31 · Create the shared stratified split
stratify=y retains class proportions. A fixed seed makes the split reproducible.
Python · Cell 31View code
# Create the shared stratified split.
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=TEST_FRACTION,
stratify=y, random_state=RANDOM_SEED
)
# Your note:
Step 32 · Verify that records do not cross the split
Disjoint row indices and duplicate checks reduce obvious contamination. Missing source IDs prevent a stronger object-level guarantee.
Python · Cell 32View code
# Verify that records do not cross the split.
assert set(X_train.index).isdisjoint(X_test.index)
assert len(X_train) + len(X_test) == len(df)
assert not X.duplicated().any(), 'Identical feature rows need grouped splitting.'
print(f'Training: {len(X_train)} | Final test: {len(X_test)}')
# Your note:
Training: 192 | Final test: 48
Step 33 · Check the split balance
This checks the evaluation design, not model scores.
Python · Cell 33View code
# Check the split balance.
split_counts = pd.DataFrame({
'training': y_train.value_counts().sort_index(),
'final_test': y_test.value_counts().sort_index()
}).rename(index=CLASS_NAMES)
split_counts
# Your note:
| training | final_test | |
|---|---|---|
| Star type | ||
| Brown Dwarf | 32 | 8 |
| Red Dwarf | 32 | 8 |
| White Dwarf | 32 | 8 |
| Main Sequence | 32 | 8 |
| Supergiant | 32 | 8 |
| Hypergiant | 32 | 8 |
Step 34 · Create a training-only exploration table
Only training records appear in the next plots and assumption checks.
Python · Cell 34View code
# Create a training-only exploration table.
train_df = df.loc[X_train.index].copy()
# Your note:
4. Explore the training data
Look for structure, unit differences, and potential quality issues. Do not automatically delete extreme values: a very large radius may be the defining feature of a giant class.
Step 35 · Summarise measurement ranges
Large differences in units can distort KNN distances. Use this table to understand the measurements, not to rank class codes as numbers.
Python · Cell 35View code
# Summarise measurement ranges.
train_df[NUMERIC_FEATURES].describe().T
# Your note:
| count | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|
| Temperature (K) | 192.0 | 10032.3229 | 8980.2271 | 1.9390e+03 | 3360.0000 | 5443.5000 | 14794.5000 | 40000.00 |
| Luminosity (L/Lo) | 192.0 | 100312.5229 | 170626.7787 | 8.0000e-05 | 0.0009 | 0.0705 | 185000.0000 | 849420.00 |
| Radius (R/Ro) | 192.0 | 235.1710 | 512.1845 | 8.4000e-03 | 0.1027 | 0.7325 | 48.7500 | 1948.50 |
| Absolute magnitude (Mv) | 192.0 | 4.3586 | 10.4728 | -1.1920e+01 | -6.2412 | 8.3130 | 13.5118 | 20.06 |
Step 36 · Plot training class counts
The named labels make the chart easier to read than unexplained numeric codes.
Python · Cell 36View code
# Plot training class counts.
fig, ax = plt.subplots(figsize=(9, 4))
ax.bar(CLASS_LABELS, split_counts['training'], color=CLASS_COLORS)
ax.set(title='Training records per dataset class', ylabel='Records', xlabel='Dataset class')
ax.tick_params(axis='x', rotation=25)
fig.tight_layout()
fig.savefig(OUTPUT_DIR / '01_class_balance.png', bbox_inches='tight')
plt.show()
# Your note:
Step 37 · Compare typical values within classes
Medians are easier to interpret when the raw numerical distributions are strongly skewed.
Python · Cell 37View code
# Compare typical values within classes.
train_df.groupby('Star category')[NUMERIC_FEATURES].median().reindex(CLASS_LABELS)
# Your note:
| Temperature (K) | Luminosity (L/Lo) | Radius (R/Ro) | Absolute magnitude (Mv) | |
|---|---|---|---|---|
| Star category | ||||
| Brown Dwarf | 2940.0 | 0.0005 | 0.1060 | 17.0300 |
| Red Dwarf | 3280.5 | 0.0032 | 0.3145 | 12.3650 |
| White Dwarf | 13165.0 | 0.0008 | 0.0105 | 12.2300 |
| Main Sequence | 11674.0 | 680.5000 | 5.8880 | -0.5500 |
| Supergiant | 12821.0 | 233000.0000 | 51.5000 | -6.2425 |
| Hypergiant | 3655.0 | 234500.0000 | 1352.5000 | -10.1600 |
Step 38 · Plot a Hertzsprung–Russell style diagram
Use a log luminosity axis and put hotter objects on the left. This is a diagram of the supplied measurements, not an independent astrophysical validation.
Python · Cell 38View code
# Plot a Hertzsprung–Russell style diagram.
fig, ax = plt.subplots(figsize=(9, 6))
for class_id, color in zip(CLASS_IDS, CLASS_COLORS):
group = train_df[train_df['Star type'].eq(class_id)]
ax.scatter(group['Temperature (K)'], group['Luminosity (L/Lo)'],
label=CLASS_NAMES[class_id], color=color, s=38, alpha=0.8)
ax.set_xscale('log')
ax.set_yscale('log')
ax.invert_xaxis()
ax.set(title='Training sample: temperature and luminosity', xlabel='Temperature (K; hotter ←)',
ylabel='Luminosity / solar luminosity (log scale)')
ax.legend(loc='center left', bbox_to_anchor=(1.02, 0.5))
fig.tight_layout()
fig.savefig(OUTPUT_DIR / '02_hr_diagram.png', bbox_inches='tight')
plt.show()
# Your note:
Step 39 · Plot temperature against visual magnitude
Both axes follow the usual diagram orientation: hotter to the left and visually brighter toward the top.
Python · Cell 39View code
# Plot temperature against visual magnitude.
fig, ax = plt.subplots(figsize=(9, 6))
sns.scatterplot(data=train_df, x='Temperature (K)', y='Absolute magnitude (Mv)',
hue='Star category', hue_order=CLASS_LABELS, palette=CLASS_COLORS, ax=ax)
ax.set_xscale('log')
ax.invert_xaxis()
ax.invert_yaxis()
ax.set(title='Training sample: temperature and visual absolute magnitude')
ax.legend(loc='center left', bbox_to_anchor=(1.02, 0.5))
fig.tight_layout()
fig.savefig(OUTPUT_DIR / '03_temperature_magnitude.png', bbox_inches='tight')
plt.show()
# Your note:
Step 40 · Inspect feature relationships
Spearman correlation compares rank relationships. A full-sample correlation does not by itself test Naive Bayes conditional independence.
Python · Cell 40View code
# Inspect feature relationships.
training_correlation = X_train.corr(method='spearman')
fig, ax = plt.subplots(figsize=(8, 6))
sns.heatmap(training_correlation, annot=True, fmt='.2f', cmap='coolwarm',
vmin=-1, vmax=1, ax=ax)
ax.set_title('Training predictors: Spearman correlation')
fig.tight_layout()
fig.savefig(OUTPUT_DIR / '04_feature_correlation.png', bbox_inches='tight')
plt.show()
# Your note:
Interpretation. The training plots show separation between several supplied classes. Radius, luminosity, and magnitude contain strong class structure; temperature alone does not describe all of it. Apparent separability is encouraging for this sample, but it may reflect how a curated dataset was constructed. Correlated physical features are a reason to question the Naive Bayes independence assumption.
Visual absolute magnitude (Mv) is already a logarithmic brightness measure for a band. It is not automatically equal to bolometric magnitude. A colour-based correction would be needed to compare it directly with total emitted luminosity.
5. Examine transformations and assumptions
Logarithms compress several orders of magnitude: luminosities of 0.001, 1, and 1,000 become −3, 0, and 3 in base 10. Temperature, luminosity, and radius are positive, so all three are log-transformed. Magnitude is kept as supplied. Standardisation then gives each transformed feature a comparable numerical scale for KNN.
Gaussian Naive Bayes models a normal distribution for each feature within each class, and assumes conditional independence. A log transform can reduce skew without guaranteeing either assumption. The official Naive Bayes documentation also cautions that its probability estimates can be poor.
Step 41 · Create transformed training values for diagnostics
This deterministic transform is only for exploration here. The model pipeline will repeat the same transform automatically.
Python · Cell 41View code
# Create transformed training values for diagnostics.
log_train = X_train.copy()
log_train[POSITIVE_FEATURES] = np.log10(log_train[POSITIVE_FEATURES])
# Your note:
Step 42 · Compare transformed distributions
Plot one panel per feature so a reader can compare classes without an oversized pair plot.
Python · Cell 42View code
# Compare transformed distributions.
plot_df = log_train.assign(category=y_train.map(CLASS_NAMES))
fig, axes = plt.subplots(2, 2, figsize=(12, 8))
for feature, ax in zip(NUMERIC_FEATURES, axes.flat):
sns.boxplot(data=plot_df, x='category', y=feature, order=CLASS_LABELS,
color='#B8C2CC', ax=ax)
ax.set_xlabel('')
ax.set_ylabel(('log10 ' if feature in POSITIVE_FEATURES else '') + feature)
ax.tick_params(axis='x', rotation=35)
fig.suptitle('Training distributions after the chosen transformation', y=1.02)
fig.tight_layout()
fig.savefig(OUTPUT_DIR / '05_transformed_distributions.png', bbox_inches='tight')
plt.show()
# Your note:
Step 43 · Check normality within each class
Run 24 training-only Shapiro–Wilk tests, one for each feature/class combination. See SciPy documentation.
Python · Cell 43View code
# Check normality within each class.
normality_rows = []
for class_id in CLASS_IDS:
for feature in NUMERIC_FEATURES:
values = log_train.loc[y_train.eq(class_id), feature]
statistic, p_value = stats.shapiro(values)
normality_rows.append({'class': CLASS_NAMES[class_id], 'feature': feature,
'n': len(values), 'W': statistic, 'p_value': p_value})
normality_table = pd.DataFrame(normality_rows)
# Your note:
Step 44 · Account for the 24 normality checks
Bonferroni adjustment is conservative and controls the family-wise error rate. These are diagnostics, not a gate used to choose a test split.
How to read the output: A small adjusted p-value is evidence against normality. A large p-value is insufficient evidence to reject it, not proof of a normal distribution.
Python · Cell 44View code
# Account for the 24 normality checks.
normality_table['p_bonferroni'] = (normality_table['p_value'] * len(normality_table)).clip(upper=1)
normality_table['evidence_against_normality'] = normality_table['p_bonferroni'].lt(0.05)
normality_table.round(4)
# Your note:
| class | feature | n | W | p_value | p_bonferroni | evidence_against_normality | |
|---|---|---|---|---|---|---|---|
| 0 | Brown Dwarf | Temperature (K) | 32 | 0.8893 | 0.0033 | 0.0804 | False |
| 1 | Brown Dwarf | Luminosity (L/Lo) | 32 | 0.9621 | 0.3133 | 1.0000 | False |
| 2 | Brown Dwarf | Radius (R/Ro) | 32 | 0.9372 | 0.0623 | 1.0000 | False |
| 3 | Brown Dwarf | Absolute magnitude (Mv) | 32 | 0.8666 | 0.0010 | 0.0233 | True |
| 4 | Red Dwarf | Temperature (K) | 32 | 0.9328 | 0.0469 | 1.0000 | False |
| 5 | Red Dwarf | Luminosity (L/Lo) | 32 | 0.9868 | 0.9563 | 1.0000 | False |
| 6 | Red Dwarf | Radius (R/Ro) | 32 | 0.9749 | 0.6441 | 1.0000 | False |
| 7 | Red Dwarf | Absolute magnitude (Mv) | 32 | 0.9580 | 0.2426 | 1.0000 | False |
| 8 | White Dwarf | Temperature (K) | 32 | 0.9489 | 0.1343 | 1.0000 | False |
| 9 | White Dwarf | Luminosity (L/Lo) | 32 | 0.8889 | 0.0033 | 0.0784 | False |
| 10 | White Dwarf | Radius (R/Ro) | 32 | 0.9548 | 0.1967 | 1.0000 | False |
| 11 | White Dwarf | Absolute magnitude (Mv) | 32 | 0.9508 | 0.1514 | 1.0000 | False |
| 12 | Main Sequence | Temperature (K) | 32 | 0.9449 | 0.1029 | 1.0000 | False |
| 13 | Main Sequence | Luminosity (L/Lo) | 32 | 0.9276 | 0.0335 | 0.8039 | False |
| 14 | Main Sequence | Radius (R/Ro) | 32 | 0.8001 | 0.0000 | 0.0010 | True |
| 15 | Main Sequence | Absolute magnitude (Mv) | 32 | 0.9030 | 0.0074 | 0.1776 | False |
| 16 | Supergiant | Temperature (K) | 32 | 0.9008 | 0.0065 | 0.1561 | False |
| 17 | Supergiant | Luminosity (L/Lo) | 32 | 0.9510 | 0.1541 | 1.0000 | False |
| 18 | Supergiant | Radius (R/Ro) | 32 | 0.9185 | 0.0189 | 0.4531 | False |
| 19 | Supergiant | Absolute magnitude (Mv) | 32 | 0.9539 | 0.1855 | 1.0000 | False |
| 20 | Hypergiant | Temperature (K) | 32 | 0.7207 | 0.0000 | 0.0000 | True |
| 21 | Hypergiant | Luminosity (L/Lo) | 32 | 0.9799 | 0.7955 | 1.0000 | False |
| 22 | Hypergiant | Radius (R/Ro) | 32 | 0.9416 | 0.0830 | 1.0000 | False |
| 23 | Hypergiant | Absolute magnitude (Mv) | 32 | 0.8738 | 0.0014 | 0.0342 | True |
Step 45 · Check relationships within classes
A maximum absolute within-class Spearman correlation highlights pairs that deserve attention. Low correlation cannot prove independence.
Python · Cell 45View code
# Check relationships within classes.
conditional_rows = []
for class_id in CLASS_IDS:
corr = log_train.loc[y_train.eq(class_id)].corr(method='spearman').abs()
pairs = corr.where(np.triu(np.ones(corr.shape), k=1).astype(bool)).stack()
pair = pairs.idxmax()
conditional_rows.append({'class': CLASS_NAMES[class_id],
'feature_1': pair[0], 'feature_2': pair[1],
'absolute_spearman': pairs.max()})
pd.DataFrame(conditional_rows).round(3)
# Your note:
| class | feature_1 | feature_2 | absolute_spearman | |
|---|---|---|---|---|
| 0 | Brown Dwarf | Temperature (K) | Luminosity (L/Lo) | 0.366 |
| 1 | Red Dwarf | Temperature (K) | Radius (R/Ro) | 0.556 |
| 2 | White Dwarf | Temperature (K) | Absolute magnitude (Mv) | 0.834 |
| 3 | Main Sequence | Temperature (K) | Luminosity (L/Lo) | 0.995 |
| 4 | Supergiant | Luminosity (L/Lo) | Radius (R/Ro) | 0.235 |
| 5 | Hypergiant | Temperature (K) | Luminosity (L/Lo) | 0.571 |
Step 46 · Run an optional physical consistency diagnostic
Assuming bolometric luminosity and effective temperature, the Stefan–Boltzmann relation gives L/L☉ ≈ (R/R☉)²(T/5772 K)⁴. The temperature scale follows IAU nominal solar constants. Definitions and uncertainties must be confirmed before treating deviations as errors.
How to read the output: Zero indicates agreement under those definitions; +1 means 10× the implied luminosity. Large deviations flag questions about metadata, not automatic row deletion.
Python · Cell 46View code
# Run an optional physical consistency diagnostic.
implied_luminosity = (
X_train['Radius (R/Ro)'] ** 2 * (X_train['Temperature (K)'] / 5772.0) ** 4
)
physics_residual = np.log10(X_train['Luminosity (L/Lo)'] / implied_luminosity)
physics_audit = pd.DataFrame({'class': y_train.map(CLASS_NAMES),
'log10_observed_over_implied_L': physics_residual})
physics_audit.groupby('class').agg(['median', 'min', 'max']).reindex(CLASS_LABELS).round(3)
# Your note:
| log10_observed_over_implied_L | |||
|---|---|---|---|
| median | min | max | |
| class | |||
| Brown Dwarf | -0.268 | -0.902 | 0.318 |
| Red Dwarf | -0.327 | -2.023 | 0.399 |
| White Dwarf | -0.704 | -1.487 | 0.353 |
| Main Sequence | -0.006 | -0.449 | 0.877 |
| Supergiant | 0.607 | -0.860 | 3.854 |
| Hypergiant | -0.322 | -4.113 | 0.108 |
What this changes in the interpretation. Complete cells and high classification accuracy can coexist with questionable physical metadata. Retain the records for this labelled-data exercise, document the assumptions, and ask a domain specialist to verify catalogue provenance before making scientific claims. The classifier is not learning a complete physical theory of stellar evolution.
6. Build simple, reusable pipelines
| Model | Preprocessing | Why it is included |
|---|---|---|
| Most-frequent baseline | None | Quantifies what a rule with no feature knowledge achieves |
| Gaussian Naive Bayes | Log positive physical features, then standardise | Retains the original probabilistic model and tests a simple distributional approach |
| KNN | The same log transform and standardisation | Learns from proximity; feature scale matters |
| Decision tree | Raw numeric measurements | A comparison with readable thresholds and fewer distributional assumptions |
Different algorithms may need different preprocessing while still using the same records and evaluation design. Every learned transformation is fitted inside its training fold. The earlier scaler.fit_transform(X) discarded the transformed array, so subsequent models still received raw values.
Step 47 · Define the log transformer
The first three columns are transformed; the remaining magnitude column passes through. The returned transformer can be cloned and saved.
Python · Cell 47View code
# Define the log transformer.
def make_log_preprocessor():
return ColumnTransformer(
[('log10', FunctionTransformer(np.log10, feature_names_out='one-to-one'), POSITIVE_FEATURES)],
remainder='passthrough', verbose_feature_names_out=False
)
# Your note:
Step 48 · Build the Naive Bayes pipeline
Scaling is attached to the model. It will be learned again within each cross-validation training fold.
Python · Cell 48View code
# Build the Naive Bayes pipeline.
nb_pipeline = Pipeline([
('log_features', make_log_preprocessor()),
('scale', StandardScaler()),
('classifier', GaussianNB())
])
# Your note:
Step 49 · Build the KNN pipeline
The pipeline applies the same numerical treatment to training and prediction inputs.
Python · Cell 49View code
# Build the KNN pipeline.
knn_pipeline = Pipeline([
('log_features', make_log_preprocessor()),
('scale', StandardScaler()),
('classifier', KNeighborsClassifier())
])
# Your note:
Step 50 · Build the decision tree pipeline
Trees can split the raw measurements directly. Depth and leaf size are tuned later to limit unnecessary complexity.
Python · Cell 50View code
# Build the decision tree pipeline.
tree_pipeline = Pipeline([
('classifier', DecisionTreeClassifier(random_state=RANDOM_SEED))
])
# Your note:
Step 51 · Build the no-skill baseline
All training class counts are equal, so the most-frequent rule chooses one tied class. Its test accuracy should be 1/6, or 16.67%.
Python · Cell 51View code
# Build the no-skill baseline.
baseline = DummyClassifier(strategy='most_frequent')
# Your note:
Step 52 · Show what log transformation and scaling do
Fit this demonstration on training records only. This illustration is separate from the models, whose own scalers stay inside their pipelines.
How to read the output: Means are near zero. StandardScaler uses population variance; pandas std uses sample variance, so the displayed standard deviations are slightly above one.
Python · Cell 52View code
# Show what log transformation and scaling do.
demo_preprocessor = Pipeline([('log_features', make_log_preprocessor()), ('scale', StandardScaler())])
scaled_training = demo_preprocessor.fit_transform(X_train)
pd.DataFrame(scaled_training, columns=NUMERIC_FEATURES).agg(['mean', 'std']).round(3)
# Your note:
| Temperature (K) | Luminosity (L/Lo) | Radius (R/Ro) | Absolute magnitude (Mv) | |
|---|---|---|---|---|
| mean | 0.000 | -0.000 | -0.000 | 0.000 |
| std | 1.003 | 1.003 | 1.003 | 1.003 |
7. Tune settings using training data
Primary selection metric: macro F1. Compute F1 for each class and average equally. This makes weak performance on one class visible rather than letting common classes dominate. Accuracy and balanced accuracy provide additional views; they equal one another on this equally balanced test sample.
Precision asks how often a predicted class is correct. Recall asks how much of an actual class is recovered. The per-class F1 score balances precision and recall. Macro F1 is the mean of the six class F1 scores; it is not the F1 of the averaged precision and recall.
The grids below are deliberately small and specified before final test evaluation. Cross-validation search scores are useful for tuning but optimistic as an estimate of the selected configuration's performance. The next section adds nested validation.
Step 53 · Define common validation folds
All searches receive the same stratified, shuffled folds and the same fixed seed.
Python · Cell 53View code
# Define common validation folds.
tuning_cv = StratifiedKFold(n_splits=CV_FOLDS, shuffle=True, random_state=RANDOM_SEED)
# Your note:
Step 54 · Define Naive Bayes candidate settings
var_smoothing stabilises small variances. Candidate values are set in advance rather than chosen from the final holdout.
Python · Cell 54View code
# Define Naive Bayes candidate settings.
nb_grid = {'classifier__var_smoothing': [1e-11, 1e-9, 1e-7, 1e-5, 1e-3]}
# Your note:
Step 55 · Define KNN candidate settings
Tune neighbour count and weighting; Euclidean distance is fixed to keep the lesson focused.
Python · Cell 55View code
# Define KNN candidate settings.
knn_grid = {
'classifier__n_neighbors': [1, 3, 5, 7, 9, 11],
'classifier__weights': ['uniform', 'distance']
}
# Your note:
Step 56 · Define tree candidate settings
Depth controls the number of splits; leaf size stops a terminal rule from relying on too few records.
Python · Cell 56View code
# Define tree candidate settings.
tree_grid = {
'classifier__max_depth': [2, 3, 4, 5, None],
'classifier__min_samples_leaf': [1, 2, 4]
}
# Your note:
Step 57 · Create one search factory
Keep refit and scoring consistent. refit trains the best configuration on all training records, never on the final holdout.
Python · Cell 57View code
# Create one search factory.
def make_search(pipeline, grid, cv):
return GridSearchCV(
pipeline, grid, cv=cv, scoring={'macro_f1': 'f1_macro', 'accuracy': 'accuracy'},
refit='macro_f1', return_train_score=True, n_jobs=N_JOBS, error_score='raise'
)
# Your note:
Step 58 · Tune Naive Bayes
Only X_train and y_train enter the search.
Python · Cell 58View code
# Tune Naive Bayes.
nb_search = make_search(nb_pipeline, nb_grid, tuning_cv)
nb_search.fit(X_train, y_train)
print('Best settings:', nb_search.best_params_)
# Your note:
Best settings: {'classifier__var_smoothing': 0.001}
Step 59 · Tune KNN
The scaler is fitted separately for every candidate in every training fold.
Python · Cell 59View code
# Tune KNN.
knn_search = make_search(knn_pipeline, knn_grid, tuning_cv)
knn_search.fit(X_train, y_train)
print('Best settings:', knn_search.best_params_)
# Your note:
Best settings: {'classifier__n_neighbors': 3, 'classifier__weights': 'uniform'}
Step 60 · Tune the tree
The tree receives exactly the same training records and validation folds as the other methods.
Python · Cell 60View code
# Tune the tree.
tree_search = make_search(tree_pipeline, tree_grid, tuning_cv)
tree_search.fit(X_train, y_train)
print('Best settings:', tree_search.best_params_)
# Your note:
Best settings: {'classifier__max_depth': 5, 'classifier__min_samples_leaf': 1}
Step 61 · Collect training search results
Display tuning performance and the training-to-validation gap. Fold standard deviation measures fold variation; it is not a confidence interval.
Python · Cell 61View code
# Collect training search results.
searches = {'Gaussian Naive Bayes': nb_search, 'KNN': knn_search, 'Decision tree': tree_search}
search_rows = []
for name, search in searches.items():
i = search.best_index_
search_rows.append({'model': name, 'CV_macro_F1': search.best_score_,
'CV_fold_SD': search.cv_results_['std_test_macro_f1'][i],
'training_macro_F1': search.cv_results_['mean_train_macro_f1'][i],
'best_parameters': str(search.best_params_)})
search_summary = pd.DataFrame(search_rows).set_index('model')
search_summary.round(4)
# Your note:
| CV_macro_F1 | CV_fold_SD | training_macro_F1 | best_parameters | |
|---|---|---|---|---|
| model | ||||
| Gaussian Naive Bayes | 1.0000 | 0.0000 | 0.9974 | {'classifier__var_smoothing': 0.001} |
| KNN | 0.9949 | 0.0103 | 0.9987 | {'classifier__n_neighbors': 3, 'classifier__weights': 'uniform'} |
| Decision tree | 1.0000 | 0.0000 | 1.0000 | {'classifier__max_depth': 5, 'classifier__min_samples_leaf': 1} |
Step 62 · Inspect KNN tuning
A chart makes the effect of neighbour count visible. The values still come from training cross-validation.
Python · Cell 62View code
# Inspect KNN tuning.
knn_cv_table = pd.DataFrame(knn_search.cv_results_)
fig, ax = plt.subplots(figsize=(8, 4))
for weight, group in knn_cv_table.groupby('param_classifier__weights'):
ax.errorbar(group['param_classifier__n_neighbors'].astype(int), group['mean_test_macro_f1'],
yerr=group['std_test_macro_f1'], marker='o', capsize=3, label=weight)
ax.set(title='KNN: training CV macro F1', xlabel='Number of neighbours', ylabel='Macro F1')
ax.legend(title='Weighting')
fig.tight_layout()
fig.savefig(OUTPUT_DIR / '06_knn_tuning.png', bbox_inches='tight')
plt.show()
# Your note:
8. Add a stronger model-selection check
This is the advanced part; the underlying idea is simple. Inner folds tune settings; outer folds assess that tuning procedure on different training records. The final test is still untouched. See the official nested cross-validation example.
Use the mean outer-fold macro F1 to choose a model family. These are five overlapping-training-fold estimates, not five independent experiments. After choosing a family, its previously fitted five-fold search provides the final settings. Family selection itself still introduces optimism into the winning nested mean, so the shared holdout remains the final estimate. No statistical superiority is inferred from small score differences.
Step 63 · Define inner and outer folds
Nested CV stays entirely within the 192 training records. Three inner folds keep this small tutorial quick.
Python · Cell 63View code
# Define inner and outer folds.
inner_cv = StratifiedKFold(n_splits=3, shuffle=True, random_state=RANDOM_SEED)
outer_cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=RANDOM_SEED + 1)
# Your note:
Step 64 · Evaluate the Naive Bayes tuning procedure
Each outer fold runs a fresh inner search. The outer validation fold never enters that search.
Python · Cell 64View code
# Evaluate the Naive Bayes tuning procedure.
nb_nested = cross_validate(
make_search(nb_pipeline, nb_grid, inner_cv), X_train, y_train,
cv=outer_cv, scoring={'macro_f1': 'f1_macro', 'accuracy': 'accuracy'},
n_jobs=N_JOBS, error_score='raise'
)
# Your note:
Step 65 · Evaluate the KNN tuning procedure
Use the same outer folds for a fair family comparison.
Python · Cell 65View code
# Evaluate the KNN tuning procedure.
knn_nested = cross_validate(
make_search(knn_pipeline, knn_grid, inner_cv), X_train, y_train,
cv=outer_cv, scoring={'macro_f1': 'f1_macro', 'accuracy': 'accuracy'},
n_jobs=N_JOBS, error_score='raise'
)
# Your note:
Step 66 · Evaluate the tree tuning procedure
The complexity choices are retuned within each outer training fold.
Python · Cell 66View code
# Evaluate the tree tuning procedure.
tree_nested = cross_validate(
make_search(tree_pipeline, tree_grid, inner_cv), X_train, y_train,
cv=outer_cv, scoring={'macro_f1': 'f1_macro', 'accuracy': 'accuracy'},
n_jobs=N_JOBS, error_score='raise'
)
# Your note:
Step 67 · Compare nested validation results
Keep fold scores so the summary can be audited.
Python · Cell 67View code
# Compare nested validation results.
nested_results = {'Gaussian Naive Bayes': nb_nested, 'KNN': knn_nested, 'Decision tree': tree_nested}
nested_fold_scores = pd.DataFrame({name: result['test_macro_f1'] for name, result in nested_results.items()})
nested_summary = pd.DataFrame({
'nested_macro_F1': nested_fold_scores.mean(),
'outer_fold_SD': nested_fold_scores.std(ddof=1),
'nested_accuracy': {name: result['test_accuracy'].mean() for name, result in nested_results.items()}
})
nested_summary.index.name = 'model'
nested_summary.sort_values('nested_macro_F1', ascending=False).round(4)
# Your note:
| nested_macro_F1 | outer_fold_SD | nested_accuracy | |
|---|---|---|---|
| model | |||
| Decision tree | 0.9944 | 0.0125 | 0.9947 |
| Gaussian Naive Bayes | 0.9849 | 0.0229 | 0.9846 |
| KNN | 0.9793 | 0.0339 | 0.9795 |
Step 68 · Select the family before opening the holdout
Choose by training nested macro F1. An exact tie is resolved by the fixed order: Naive Bayes, KNN, then tree.
Python · Cell 68View code
# Select the family before opening the holdout.
winner_name = nested_summary['nested_macro_F1'].idxmax()
selected_model = searches[winner_name].best_estimator_
print('Selected family:', winner_name)
print('Selected parameters:', searches[winner_name].best_params_)
# Your note:
Selected family: Decision tree
Selected parameters: {'classifier__max_depth': 5, 'classifier__min_samples_leaf': 1}
9. Evaluate the locked final test
All models below are already fitted on training data, apart from the baseline which is fitted in the next cell. The model family is fixed. Comparing holdout scores is descriptive and does not reopen model selection. Every metric is computed from that model's own predictions on the same 48 records.
Step 69 · Fit the baseline on training labels
The baseline establishes how much useful signal the fitted models add.
Python · Cell 69View code
# Fit the baseline on training labels.
baseline.fit(X_train, y_train)
# Your note:
DummyClassifier(strategy='most_frequent')
Step 70 · Collect fitted models
Store the selected family and all comparators without refitting on test records.
Python · Cell 70View code
# Collect fitted models.
fitted_models = {'Most-frequent baseline': baseline}
fitted_models.update({name: search.best_estimator_ for name, search in searches.items()})
# Your note:
Step 71 · Generate final test predictions
One prediction array per model prevents accidental reuse of another model's F1 score.
Python · Cell 71View code
# Generate final test predictions.
test_predictions = {name: model.predict(X_test) for name, model in fitted_models.items()}
# Your note:
Step 72 · Define classification metrics
The class list is explicit, and zero_division handles a baseline that never predicts five of the classes.
Python · Cell 72View code
# Define classification metrics.
def classification_metrics(actual, predicted):
return {
'accuracy': accuracy_score(actual, predicted),
'balanced_accuracy': balanced_accuracy_score(actual, predicted),
'macro_precision': precision_score(actual, predicted, labels=CLASS_IDS, average='macro', zero_division=0),
'macro_recall': recall_score(actual, predicted, labels=CLASS_IDS, average='macro', zero_division=0),
'macro_F1': f1_score(actual, predicted, labels=CLASS_IDS, average='macro', zero_division=0)
}
# Your note:
Step 73 · Compare the shared holdout metrics
Rows stay in the predefined order; the selected family remains the one chosen by nested training CV.
Python · Cell 73View code
# Compare the shared holdout metrics.
test_metrics = pd.DataFrame({
name: classification_metrics(y_test, predicted)
for name, predicted in test_predictions.items()
}).T
test_metrics.index.name = 'model'
test_metrics['selected_before_test'] = test_metrics.index == winner_name
test_metrics.round(4)
# Your note:
| accuracy | balanced_accuracy | macro_precision | macro_recall | macro_F1 | selected_before_test | |
|---|---|---|---|---|---|---|
| model | ||||||
| Most-frequent baseline | 0.1667 | 0.1667 | 0.0278 | 0.1667 | 0.0476 | False |
| Gaussian Naive Bayes | 0.9792 | 0.9792 | 0.9815 | 0.9792 | 0.9791 | False |
| KNN | 1.0000 | 1.0000 | 1.0000 | 1.0000 | 1.0000 | False |
| Decision tree | 1.0000 | 1.0000 | 1.0000 | 1.0000 | 1.0000 | True |
Step 74 · Plot model accuracy and macro F1
This comparison exposes weaknesses that a single accuracy figure can hide.
Python · Cell 74View code
# Plot model accuracy and macro F1.
fig, ax = plt.subplots(figsize=(9, 5))
test_metrics[['accuracy', 'macro_F1']].plot.bar(ax=ax, color=['#16324F', '#C7353F'])
ax.set(ylim=(0, 1.07), title='Same final holdout: model comparison', ylabel='Score', xlabel='')
ax.tick_params(axis='x', rotation=18)
fig.tight_layout()
fig.savefig(OUTPUT_DIR / '07_model_comparison.png', bbox_inches='tight')
plt.show()
# Your note:
Step 75 · Report the selected model result
State the denominator beside the percentage and keep the training and test estimates distinct.
Python · Cell 75View code
# Report the selected model result.
selected_predictions = test_predictions[winner_name]
selected_accuracy = test_metrics.loc[winner_name, 'accuracy']
selected_f1 = test_metrics.loc[winner_name, 'macro_F1']
correct_count = int(np.sum(selected_predictions == y_test.to_numpy()))
print(f'{winner_name}: {correct_count}/{len(y_test)} correct ({selected_accuracy:.2%}).')
print(f'Final-test macro F1: {selected_f1:.4f}.')
print(f'Training nested-CV macro F1: {nested_summary.loc[winner_name, "nested_macro_F1"]:.4f}.')
print('These results do not establish accuracy on a new telescope survey.')
# Your note:
Decision tree: 48/48 correct (100.00%). Final-test macro F1: 1.0000. Training nested-CV macro F1: 0.9944. These results do not establish accuracy on a new telescope survey.
Step 76 · Define a Wilson accuracy interval
A Wilson interval avoids a misleading zero-width uncertainty interval when all test cases are correct. It concerns aggregate accuracy under an independent, same-distribution sampling assumption.
Python · Cell 76View code
# Define a Wilson accuracy interval.
def wilson_interval(correct, total, confidence=0.95):
z = stats.norm.ppf(0.5 + confidence / 2)
proportion = correct / total
denominator = 1 + z ** 2 / total
centre = (proportion + z ** 2 / (2 * total)) / denominator
half_width = z * np.sqrt(proportion * (1 - proportion) / total + z ** 2 / (4 * total ** 2)) / denominator
return centre - half_width, centre + half_width
# Your note:
Step 77 · Quantify holdout uncertainty
There are only 48 test records and eight per class. Unknown dependence and curated sampling limit this interval's real-world interpretation.
Python · Cell 77View code
# Quantify holdout uncertainty.
accuracy_low, accuracy_high = wilson_interval(correct_count, len(y_test))
print(f'95% Wilson interval for aggregate accuracy: {accuracy_low:.2%}–{accuracy_high:.2%}')
print(f'One additional test error changes accuracy by {1 / len(y_test):.2%}.')
# Your note:
95% Wilson interval for aggregate accuracy: 92.59%–100.00% One additional test error changes accuracy by 2.08%.
10. Inspect errors, ranking, and model scores
A confusion matrix shows which classes are confused. ROC AUC evaluates ranking across one-vs-rest comparisons; it does not mean that the same percentage of records is classified correctly. Log loss evaluates the model scores assigned to the true classes. A score of 0.95 from predict_proba is not automatically a verified 95% chance of correctness.
Step 78 · Show all learned-model confusion matrices
Rows are true classes; columns are predictions. The identical label ordering enables direct comparison.
Python · Cell 78View code
# Show all learned-model confusion matrices.
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
for (name, _), ax in zip(searches.items(), axes):
matrix = confusion_matrix(y_test, test_predictions[name], labels=CLASS_IDS)
ConfusionMatrixDisplay(matrix, display_labels=CLASS_LABELS).plot(
ax=ax, cmap='Blues', colorbar=False, xticks_rotation=75
)
ax.set_title(name)
fig.tight_layout()
fig.savefig(OUTPUT_DIR / '08_confusion_matrices.png', bbox_inches='tight')
plt.show()
# Your note:
Step 79 · Produce the selected model classification report
Support is eight per class. A perfect class score based on eight examples is still limited evidence.
Python · Cell 79View code
# Produce the selected model classification report.
report_dict = classification_report(
y_test, selected_predictions, labels=CLASS_IDS, target_names=CLASS_LABELS,
output_dict=True, zero_division=0
)
classification_report_table = pd.DataFrame(report_dict).T
classification_report_table.round(4)
# Your note:
| precision | recall | f1-score | support | |
|---|---|---|---|---|
| Brown Dwarf | 1.0 | 1.0 | 1.0 | 8.0 |
| Red Dwarf | 1.0 | 1.0 | 1.0 | 8.0 |
| White Dwarf | 1.0 | 1.0 | 1.0 | 8.0 |
| Main Sequence | 1.0 | 1.0 | 1.0 | 8.0 |
| Supergiant | 1.0 | 1.0 | 1.0 | 8.0 |
| Hypergiant | 1.0 | 1.0 | 1.0 | 8.0 |
| accuracy | 1.0 | 1.0 | 1.0 | 1.0 |
| macro avg | 1.0 | 1.0 | 1.0 | 48.0 |
| weighted avg | 1.0 | 1.0 | 1.0 | 48.0 |
Step 80 · Build a record-level prediction audit
These are CSV row indices, not astronomical source identifiers. Keep the true label, predicted label, and every input together.
Python · Cell 80View code
# Build a record-level prediction audit.
prediction_audit = X_test.copy()
prediction_audit.index.name = 'csv_row_index'
prediction_audit['actual_class'] = y_test.map(CLASS_NAMES)
prediction_audit['predicted_class'] = pd.Series(selected_predictions, index=X_test.index).map(CLASS_NAMES)
prediction_audit['correct'] = prediction_audit['actual_class'].eq(prediction_audit['predicted_class'])
# Your note:
Step 81 · Inspect the selected model mistakes
An empty table means no observed errors in this holdout; it does not demonstrate absence of future errors.
Python · Cell 81View code
# Inspect the selected model mistakes.
mistakes = prediction_audit.loc[~prediction_audit['correct']]
print('Observed holdout errors:', len(mistakes))
mistakes
# Your note:
Observed holdout errors: 0
| Temperature (K) | Luminosity (L/Lo) | Radius (R/Ro) | Absolute magnitude (Mv) | actual_class | predicted_class | correct | |
|---|---|---|---|---|---|---|---|
| csv_row_index |
Step 82 · Compare mistakes from the learned models
Inspect errors even when the selected model has none. Exclude the no-skill baseline to keep the diagnostic focused.
Python · Cell 82View code
# Compare mistakes from the learned models.
comparison_errors = []
for name in searches:
for row_index, actual, predicted in zip(X_test.index, y_test, test_predictions[name]):
if actual != predicted:
comparison_errors.append({'model': name, 'csv_row_index': row_index,
'actual': CLASS_NAMES[int(actual)],
'predicted': CLASS_NAMES[int(predicted)]})
pd.DataFrame(comparison_errors, columns=['model', 'csv_row_index', 'actual', 'predicted'])
# Your note:
| model | csv_row_index | actual | predicted | |
|---|---|---|---|---|
| 0 | Gaussian Naive Bayes | 30 | Main Sequence | Supergiant |
Step 83 · Align probability columns explicitly
Probability columns follow model.classes_. Reorder them to the declared class list before calculating multiclass metrics.
Python · Cell 83View code
# Align probability columns explicitly.
raw_probabilities = selected_model.predict_proba(X_test)
probability_order = [list(selected_model.classes_).index(class_id) for class_id in CLASS_IDS]
test_probabilities = raw_probabilities[:, probability_order]
assert np.allclose(test_probabilities.sum(axis=1), 1)
# Your note:
Step 84 · Calculate ranking and probability-quality metrics
AUC is descriptive on a small balanced holdout. Log loss can be poor even when accuracy is high if mistakes are overconfident.
Python · Cell 84View code
# Calculate ranking and probability-quality metrics.
test_auc = roc_auc_score(y_test, test_probabilities, labels=CLASS_IDS, multi_class='ovr', average='macro')
test_log_loss = log_loss(y_test, test_probabilities, labels=CLASS_IDS)
print(f'Macro one-vs-rest ROC AUC: {test_auc:.4f}')
print(f'Multiclass log loss (lower is better): {test_log_loss:.4f}')
# Your note:
Macro one-vs-rest ROC AUC: 1.0000 Multiclass log loss (lower is better): 0.0000
Step 85 · Plot one-vs-rest ROC curves
Each curve compares one named class with all other classes. Ties in scores receive half credit in AUC ranking interpretation.
Python · Cell 85View code
# Plot one-vs-rest ROC curves.
binarized_test = label_binarize(y_test, classes=CLASS_IDS)
fig, ax = plt.subplots(figsize=(8, 6))
for i, (class_id, color) in enumerate(zip(CLASS_IDS, CLASS_COLORS)):
fpr, tpr, _ = roc_curve(binarized_test[:, i], test_probabilities[:, i])
class_auc = roc_auc_score(binarized_test[:, i], test_probabilities[:, i])
ax.plot(fpr, tpr, color=color, label=f'{CLASS_NAMES[class_id]} (AUC {class_auc:.3f})')
ax.plot([0, 1], [0, 1], '--', color='#667582', label='Chance ranking')
ax.set(title=f'{winner_name}: final-test one-vs-rest ROC', xlabel='False positive rate', ylabel='True positive rate')
ax.legend(loc='lower right', fontsize=9)
fig.tight_layout()
fig.savefig(OUTPUT_DIR / '09_roc_curves.png', bbox_inches='tight')
plt.show()
# Your note:
Step 86 · Attach model scores to the audit table
A model score is useful for diagnostic review but is not labelled as calibrated confidence.
Python · Cell 86View code
# Attach model scores to the audit table.
prediction_audit['maximum_model_score'] = test_probabilities.max(axis=1)
prediction_audit[['actual_class', 'predicted_class', 'correct', 'maximum_model_score']].head(10)
# Your note:
| actual_class | predicted_class | correct | maximum_model_score | |
|---|---|---|---|---|
| csv_row_index | ||||
| 135 | Red Dwarf | Red Dwarf | True | 1.0 |
| 152 | Main Sequence | Main Sequence | True | 1.0 |
| 73 | Red Dwarf | Red Dwarf | True | 1.0 |
| 64 | Brown Dwarf | Brown Dwarf | True | 1.0 |
| 169 | Supergiant | Supergiant | True | 1.0 |
| 228 | Supergiant | Supergiant | True | 1.0 |
| 153 | Main Sequence | Main Sequence | True | 1.0 |
| 188 | Brown Dwarf | Brown Dwarf | True | 1.0 |
| 227 | Supergiant | Supergiant | True | 1.0 |
| 45 | Supergiant | Supergiant | True | 1.0 |
Step 87 · Summarise score bins
This is a coarse diagnostic with small bin counts. It does not calibrate probabilities or set an acceptance threshold.
Python · Cell 87View code
# Summarise score bins.
score_bins = pd.cut(prediction_audit['maximum_model_score'],
bins=[0, 0.5, 0.8, 0.95, 1.0], include_lowest=True)
score_diagnostic = prediction_audit.groupby(score_bins, observed=True).agg(
records=('correct', 'size'), mean_score=('maximum_model_score', 'mean'),
observed_accuracy=('correct', 'mean')
)
score_diagnostic.round(4)
# Your note:
| records | mean_score | observed_accuracy | |
|---|---|---|---|
| maximum_model_score | |||
| (0.95, 1.0] | 48 | 1.0 | 1.0 |
Decision implication. To build a review queue, validate calibration and class-specific precision/recall on an independent, representative dataset. Define review thresholds using development data and the cost of errors. The final holdout above cannot now be reused to tune that policy. For rare-object searches, recall, follow-up yield, and contamination rate may matter more than overall accuracy.
11. Explain the fitted models
The tree provides human-readable rules. Permutation importance estimates how a fitted model's score changes when a column is shuffled. Correlated predictors can share signal, so a low score drop does not establish physical irrelevance or causation.
Step 88 · Print readable tree rules
These thresholds use the raw measurement units. The rules are a comparator even if the tree was not the selected family.
Python · Cell 88View code
# Print readable tree rules.
fitted_tree = tree_search.best_estimator_.named_steps['classifier']
print(export_text(fitted_tree, feature_names=NUMERIC_FEATURES, decimals=3))
# Your note:
|--- Radius (R/Ro) <= 0.046 | |--- class: 2 |--- Radius (R/Ro) > 0.046 | |--- Radius (R/Ro) <= 0.733 | | |--- Absolute magnitude (Mv) <= 15.495 | | | |--- class: 1 | | |--- Absolute magnitude (Mv) > 15.495 | | | |--- class: 0 | |--- Radius (R/Ro) > 0.733 | | |--- Absolute magnitude (Mv) <= -7.515 | | | |--- class: 5 | | |--- Absolute magnitude (Mv) > -7.515 | | | |--- Radius (R/Ro) <= 11.100 | | | | |--- class: 3 | | | |--- Radius (R/Ro) > 11.100 | | | | |--- class: 4
Step 89 · Draw the learned tree
Class codes at the leaves map to the named classes shown elsewhere in the notebook.
Python · Cell 89View code
# Draw the learned tree.
fig, ax = plt.subplots(figsize=(17, 9))
plot_tree(fitted_tree, feature_names=NUMERIC_FEATURES, class_names=CLASS_LABELS,
filled=True, rounded=True, impurity=False, proportion=False, ax=ax, fontsize=8)
ax.set_title('Tuned decision tree: rules learned from training records')
fig.tight_layout()
fig.savefig(OUTPUT_DIR / '10_tree_rules.png', bbox_inches='tight')
plt.show()
# Your note:
Step 90 · Estimate exploratory feature importance
Use training records only so explanation does not guide changes using the final holdout. These in-sample importances may be optimistic.
Python · Cell 90View code
# Estimate exploratory feature importance.
importance_result = permutation_importance(
selected_model, X_train, y_train, scoring='f1_macro',
n_repeats=20, random_state=RANDOM_SEED, n_jobs=N_JOBS
)
importance_table = pd.DataFrame({
'feature': NUMERIC_FEATURES, 'mean_F1_drop': importance_result.importances_mean,
'shuffle_SD': importance_result.importances_std
}).sort_values('mean_F1_drop', ascending=False)
importance_table.round(4)
# Your note:
| feature | mean_F1_drop | shuffle_SD | |
|---|---|---|---|
| 2 | Radius (R/Ro) | 0.6589 | 0.0352 |
| 3 | Absolute magnitude (Mv) | 0.3949 | 0.0275 |
| 1 | Luminosity (L/Lo) | 0.0000 | 0.0000 |
| 0 | Temperature (K) | 0.0000 | 0.0000 |
Step 91 · Plot exploratory importance
Error bars show variation across random shuffles, not statistical confidence intervals.
Python · Cell 91View code
# Plot exploratory importance.
fig, ax = plt.subplots(figsize=(9, 4))
ordered_importance = importance_table.sort_values('mean_F1_drop')
ax.barh(ordered_importance['feature'], ordered_importance['mean_F1_drop'],
xerr=ordered_importance['shuffle_SD'], color='#16324F', capsize=3)
ax.set(title=f'{winner_name}: exploratory training permutation importance',
xlabel='Drop in training macro F1 after shuffling')
fig.tight_layout()
fig.savefig(OUTPUT_DIR / '11_permutation_importance.png', bbox_inches='tight')
plt.show()
# Your note:
12. Make candidate predictions with input checks
The original example supplied Mv = 612, far outside the data range, and still returned a label. A production-facing function needs units, column checks, numerical validation, and an explicit review route for unsupported inputs.
The demonstration below uses existing held-out records, not newly collected stars. All suggestions remain candidates for expert review. A simple min/max range check cannot detect every unusual combination of otherwise in-range measurements.
Step 92 · Define measurement validation
Require named columns, finite numerical values, and positive inputs to logarithms. Reject missing, additional, or malformed fields with a clear error.
Python · Cell 92View code
# Define measurement validation.
def validate_measurements(measurements):
if not isinstance(measurements, pd.DataFrame):
raise TypeError('Supply a pandas DataFrame with named measurement columns.')
if not measurements.columns.is_unique or set(measurements.columns) != set(NUMERIC_FEATURES):
raise ValueError('Supply exactly the four named predictor columns.')
if measurements.empty:
raise ValueError('Supply at least one observation.')
numeric = measurements.loc[:, NUMERIC_FEATURES].apply(pd.to_numeric, errors='raise')
if not np.isfinite(numeric.to_numpy()).all():
raise ValueError('Measurements must be finite and non-missing.')
if not numeric[POSITIVE_FEATURES].gt(0).all().all():
raise ValueError('Temperature, luminosity, and radius must be positive.')
return numeric
# Your note:
Step 93 · Record supported marginal ranges
Range limits come only from training records. They describe the sample; they are not universal physical bounds.
Python · Cell 93View code
# Record supported marginal ranges.
training_ranges = X_train.agg(['min', 'max'])
training_ranges
# Your note:
| Temperature (K) | Luminosity (L/Lo) | Radius (R/Ro) | Absolute magnitude (Mv) | |
|---|---|---|---|---|
| min | 1939 | 8.0000e-05 | 0.0084 | -11.92 |
| max | 40000 | 8.4942e+05 | 1948.5000 | 20.06 |
Step 94 · Define a candidate prediction function
Abstain when any measurement falls outside training ranges. Scores are diagnostic only, and even in-range predictions require expert confirmation.
Python · Cell 94View code
# Define a candidate prediction function.
def suggest_for_review(measurements):
numeric = validate_measurements(measurements)
outside = numeric.lt(training_ranges.loc['min']) | numeric.gt(training_ranges.loc['max'])
supported = ~outside.any(axis=1)
result = pd.DataFrame(index=numeric.index)
result['suggested_class'] = 'Abstain'
result['maximum_model_score'] = np.nan
result['review_status'] = 'Outside training range; expert review required'
if supported.any():
inputs = numeric.loc[supported]
result.loc[supported, 'suggested_class'] = [CLASS_NAMES[int(c)] for c in selected_model.predict(inputs)]
result.loc[supported, 'maximum_model_score'] = selected_model.predict_proba(inputs).max(axis=1)
result.loc[supported, 'review_status'] = 'Candidate only; expert confirmation required'
return result
# Your note:
Step 95 · Create demonstration inputs
These examples are sampled from the existing holdout. The example does not provide an additional independent test.
Python · Cell 95View code
# Create demonstration inputs.
example_measurements = X_test.head(3).copy()
example_measurements
# Your note:
| Temperature (K) | Luminosity (L/Lo) | Radius (R/Ro) | Absolute magnitude (Mv) | |
|---|---|---|---|---|
| 135 | 3243 | 0.0023 | 0.730 | 14.75 |
| 152 | 14060 | 1092.0000 | 5.745 | -2.04 |
| 73 | 3150 | 0.0088 | 0.350 | 11.94 |
Step 96 · Generate candidate suggestions
Predict from named columns so feature order remains explicit.
Python · Cell 96View code
# Generate candidate suggestions.
example_suggestions = suggest_for_review(example_measurements)
example_suggestions
# Your note:
| suggested_class | maximum_model_score | review_status | |
|---|---|---|---|
| 135 | Red Dwarf | 1.0 | Candidate only; expert confirmation required |
| 152 | Main Sequence | 1.0 | Candidate only; expert confirmation required |
| 73 | Red Dwarf | 1.0 | Candidate only; expert confirmation required |
Step 97 · Demonstrate abstention for an unsupported magnitude
Use the earlier magnitude value of 612 to show how the new interface handles extrapolation.
Python · Cell 97View code
# Demonstrate abstention for an unsupported magnitude.
unsupported_example = example_measurements.iloc[[0]].copy()
unsupported_example['Absolute magnitude (Mv)'] = 612.0
suggest_for_review(unsupported_example)
# Your note:
| suggested_class | maximum_model_score | review_status | |
|---|---|---|---|
| 135 | Abstain | NaN | Outside training range; expert review required |
13. Translate the workflow into stakeholder value
Astronomy: organise catalogues and prioritise follow-up
A larger, verified version of this workflow could propose labels for survey sources, identify ambiguous records for expert inspection, and help build candidate lists for particular stellar populations. Scientifically useful evaluation would include class-specific completeness, contamination, measurement uncertainty, extinction, unresolved binaries, and differences between instruments. Rare-object follow-up would use uncertainty and scientific value, rather than automatically observing whichever class has the highest model score.
This is a plausible application of the method, not a validated use of this fitted model. Actual survey work uses richer inputs. For context, the Gaia Apsis methods paper describes large-scale source classification and astrophysical parameter estimation from spectra, astrometry, and photometry. It establishes that the task is relevant; it does not validate this six-class tutorial or make its labels equivalent to Gaia's.
Business: sell dependable software and services around the workflow
Relevant customers could include astronomy software providers, observatories buying data-processing services, and educational technology organisations. The value would come from reducing repetitive catalogue work, documenting quality, and providing usable review tools. A credible product would pair predictions with source metadata, validation evidence, an audit trail, and a way for users to correct suggestions.
The broader portfolio value is transferable: the same discipline applies to customer segmentation, document routing, quality inspection, and other labelled-data problems, once domain-specific data and risks are validated. The star classifier itself does not analyse customers or predict commercial demand.
| Audience | Proposed decision support | Evidence to collect | Useful success metric |
|---|---|---|---|
| Observatory analyst | Triage a large incoming catalogue | Independent instrument/source-separated labels and error bars | Expert minutes per verified source; contamination by class |
| Telescope team | Build candidate lists for a science programme | Position, visibility, spectrum, uncertainty, and scientific priority | Useful discoveries or confirmations per observing hour |
| Astronomy software business | Offer a documented classification/review tool | Pilot users, representative error rates, running costs | Cost per verified record; correction rate; adoption |
| Educational platform | Teach a complete supervised-learning workflow | Student feedback and assessment of learning | Completion and improvement in modelling reasoning |
| Public research funder | Compare reproducible analysis outputs | Provenance, external validation, staff effort, and access | Cost per verified catalogue release; reproducibility |
Government: research capacity and accountable public investment
For South Africa, the strongest connection is public astronomy infrastructure and scientific skills. SALT's official organisation page describes an international academic/government partnership, a research facility, and an education mission. An inference from that context is that verified analysis tools could support research productivity, student training, and reproducible publicly funded data products.
Funders would need measured project outcomes: verified catalogues released, researcher hours per product, independent scientific use, training outcomes, and maintained software. A balanced 240-row file does not estimate the Galactic population, national economic benefit, or the return on an observatory's budget. Those questions need population-aware surveys and project financial/operational data.
Auditing perspective
The checksum, fixed split, class mapping, version records, exported predictions, and documented selection rule make the analysis inspectable. An auditor could trace a reported metric back to specific CSV rows and code. Scientific provenance remains incomplete, so the audit trail proves reproducibility of this computation rather than correctness of the underlying measurements. A real service would also retain source IDs, data-access permissions, model versions, human corrections, and release approval records.
Illustrative workload scenario — not a measured saving
Suppose a future, externally validated system receives 10,000 records and a manual review takes three minutes per record. If some records can be routed through a validated assisted process, staff review the remainder plus a 5% quality sample of the routed records. Assume one second of serial model processing per record. The next cells compare gross processing effort only. They omit integration, model development, oversight, specialist follow-up, rework, and software costs. The routing fractions are assumptions, not fractions achieved by this notebook.
Step 98 · Specify workload assumptions
Keep business assumptions separate from scientific measurements.
Python · Cell 98View code
# Specify workload assumptions.
SCENARIO_RECORDS = 10_000
MANUAL_MINUTES = 3.0
ASSISTED_SAMPLE_RATE = 0.05
INFERENCE_SECONDS = 1.0
ASSUMED_ROUTING_FRACTIONS = [0.0, 0.5, 0.7, 0.9]
# Your note:
Step 99 · Calculate hypothetical processing effort
Review the non-routed records and sample the routed ones. Apply processing time only when the assisted system is used.
Python · Cell 99View code
# Calculate hypothetical processing effort.
manual_hours = SCENARIO_RECORDS * MANUAL_MINUTES / 60
scenario_rows = []
for fraction in ASSUMED_ROUTING_FRACTIONS:
reviewed_fraction = 1 - fraction + fraction * ASSISTED_SAMPLE_RATE
review_hours = SCENARIO_RECORDS * reviewed_fraction * MANUAL_MINUTES / 60
processing_hours = SCENARIO_RECORDS * INFERENCE_SECONDS / 3600 if fraction > 0 else 0
scenario_rows.append({'assumed_routed_share': fraction, 'human_review_hours': review_hours,
'serial_processing_hours': processing_hours,
'gross_effort_hours': review_hours + processing_hours,
'gross_hours_avoided': manual_hours - review_hours - processing_hours})
workload_scenarios = pd.DataFrame(scenario_rows)
workload_scenarios.round(2)
# Your note:
| assumed_routed_share | human_review_hours | serial_processing_hours | gross_effort_hours | gross_hours_avoided | |
|---|---|---|---|---|---|
| 0 | 0.0 | 500.0 | 0.00 | 500.00 | 0.00 |
| 1 | 0.5 | 262.5 | 2.78 | 265.28 | 234.72 |
| 2 | 0.7 | 167.5 | 2.78 | 170.28 | 329.72 |
| 3 | 0.9 | 72.5 | 2.78 | 75.28 | 424.72 |
Step 100 · Visualise the assumed workload
The chart is a sensitivity analysis. It does not establish ROI or the quality of automatically routed labels.
Python · Cell 100View code
# Visualise the assumed workload.
fig, ax = plt.subplots(figsize=(8, 4))
ax.bar(workload_scenarios['assumed_routed_share'].map(lambda x: f'{x:.0%}'),
workload_scenarios['gross_effort_hours'], color='#16324F')
ax.axhline(manual_hours, linestyle='--', color='#C7353F', label='Manual-only assumption')
ax.set(title='Hypothetical workload — not measured business savings',
xlabel='Assumed share routed through assisted processing', ylabel='Gross effort (hours)')
ax.legend()
fig.tight_layout()
fig.savefig(OUTPUT_DIR / '12_workload_scenario.png', bbox_inches='tight')
plt.show()
# Your note:
14. State what the evidence supports
| Limitation | Why it matters | Next evidence needed |
|---|---|---|
| 240 curated records; 48 in the final test | A few cases substantially change the reported score | A larger independent test with a justified sampling design |
| Equal class counts | Sample prevalence does not describe the sky or a customer's incoming records | Representative prevalence and class-specific evaluation |
| No catalogue/source IDs or instrument metadata | Row-level disjointness cannot guarantee object/instrument independence | Source-, survey-, time-, or instrument-separated splits |
| No measurement uncertainties | Input noise and derived-parameter uncertainty are unmodelled | Uncertainty-aware features and sensitivity analysis |
| Simplified and potentially overlapping physical categories | Dataset labels may differ from a professional classification scheme | Expert-reviewed target definitions |
| Luminosity/temperature definitions need confirmation | A physical consistency check relies on those definitions | Original metadata, passbands, and error bars |
| Model scores are uncalibrated | High numeric scores may still be wrong | A separate calibration/development set and external verification |
| Marginal range check only | In-range values may form unseen or implausible combinations | Joint distribution/OOD checks and specialist rules |
| No actual business-cost data | A workload illustration does not demonstrate realised savings | A pilot with observed effort, quality, rework, and total costs |
What this portfolio demonstrates: readable code, careful label handling, train/test discipline, appropriate preprocessing, comparative modelling, uncertainty reporting, reproducible exports, and a connection between technical evidence and decisions.
What it does not demonstrate: confirmed discovery of new stellar types, a deployable survey classifier, reliable class-population estimates, or a measured financial return.
15. Export the results and preserve the audit trail
The output folder contains the selected fitted pipeline, charts, tables, holdout predictions, and metadata. The saved pipeline stays fitted on the 192 training records so its predictions can reproduce the final evaluation. It is not refitted on all 240 records.
Step 101 · Export evaluation tables
CSV tables support a website, further review, or a report without copying numbers by hand.
Python · Cell 101View code
# Export evaluation tables.
test_metrics.to_csv(OUTPUT_DIR / 'model_comparison.csv')
search_summary.to_csv(OUTPUT_DIR / 'training_search_summary.csv')
nested_summary.to_csv(OUTPUT_DIR / 'nested_cv_summary.csv')
nested_fold_scores.to_csv(OUTPUT_DIR / 'nested_cv_fold_scores.csv', index_label='outer_fold')
classification_report_table.to_csv(OUTPUT_DIR / 'selected_classification_report.csv')
# Your note:
Step 102 · Export the record-level audit
Retain model-specific predictions for every comparator as well as the selected model's scored audit.
Python · Cell 102View code
# Export the record-level audit.
prediction_audit.to_csv(OUTPUT_DIR / 'holdout_prediction_audit.csv')
all_model_predictions = pd.DataFrame(test_predictions, index=X_test.index)
all_model_predictions.insert(0, 'actual_class_code', y_test)
all_model_predictions.to_csv(OUTPUT_DIR / 'all_model_holdout_predictions.csv', index_label='csv_row_index')
# Your note:
Step 103 · Export diagnostic tables
Keep assumption checks, exploratory explanations, and hypothetical workloads identifiable.
Python · Cell 103View code
# Export diagnostic tables.
normality_table.to_csv(OUTPUT_DIR / 'training_normality_diagnostics.csv', index=False)
physics_audit.to_csv(OUTPUT_DIR / 'training_physics_diagnostic.csv', index_label='csv_row_index')
importance_table.to_csv(OUTPUT_DIR / 'exploratory_training_importance.csv', index=False)
workload_scenarios.to_csv(OUTPUT_DIR / 'hypothetical_workload_scenarios.csv', index=False)
training_ranges.to_csv(OUTPUT_DIR / 'training_input_ranges.csv')
# Your note:
Step 104 · Save the complete fitted pipeline
Saving preprocessing with the classifier prevents inconsistent prediction inputs. Only load joblib files you trust.
Python · Cell 104View code
# Save the complete fitted pipeline.
joblib.dump(selected_model, OUTPUT_DIR / 'selected_stellar_pipeline.joblib')
# Your note:
['outputs/selected_stellar_pipeline.joblib']
Step 105 · Check that the saved pipeline reproduces predictions
Reload the file just created and compare its holdout predictions with the recorded result.
Python · Cell 105View code
# Check that the saved pipeline reproduces predictions.
reloaded_model = joblib.load(OUTPUT_DIR / 'selected_stellar_pipeline.joblib')
assert np.array_equal(reloaded_model.predict(X_test), selected_predictions)
print('Reloaded pipeline reproduces all selected-model holdout predictions.')
# Your note:
Reloaded pipeline reproduces all selected-model holdout predictions.
Step 106 · Build reproducibility metadata
Preserve exact row membership, versions, settings, selection criteria, and the uncertainty interval.
Python · Cell 106View code
# Build reproducibility metadata.
run_metadata = {
'project': 'Stellar Classification: From Data to Decisions', 'author': 'Gareth Grant',
'data_file': DATA_PATH.name, 'data_sha256': data_sha256,
'source_url_from_original_submission': 'https://www.kaggle.com/datasets/deepu1109/star-dataset',
'provenance_status': 'Uploaded file verified internally; catalogue lineage and observational metadata unavailable',
'python_version': platform.python_version(), 'package_versions': package_versions,
'random_seed': RANDOM_SEED, 'test_fraction': TEST_FRACTION,
'training_row_indices': sorted(map(int, X_train.index)),
'test_row_indices': sorted(map(int, X_test.index)),
'predictors': NUMERIC_FEATURES, 'classes': CLASS_NAMES,
'selection_rule': 'Highest training nested-CV mean macro F1; fixed family-order tie break',
'selected_model': winner_name, 'selected_parameters': searches[winner_name].best_params_,
'final_test_metrics': classification_metrics(y_test, selected_predictions),
'correct_test_records': correct_count, 'test_records': len(y_test),
'wilson_accuracy_interval_95': [accuracy_low, accuracy_high],
'macro_ovr_auc': test_auc, 'multiclass_log_loss': test_log_loss,
'model_fit_scope': '192 training rows only; final test excluded'
}
# Your note:
Step 107 · Save the run metadata
The metadata makes the computation reviewable; it does not fill the gaps in scientific provenance.
Python · Cell 107View code
# Save the run metadata.
(OUTPUT_DIR / 'run_metadata.json').write_text(json.dumps(run_metadata, indent=2), encoding='utf-8')
print('Saved run_metadata.json')
# Your note:
Saved run_metadata.json
16. Write the result as a website case study
The next cell creates a concise summary from the computed results. This avoids manually retaining a claim when code or data change. Use the complete tutorial for readers who want to inspect the code, and the short summary for visitors assessing your skills.
Step 108 · Generate a result-grounded portfolio summary
The wording describes the method and the observed holdout, while keeping future uses separate from demonstrated results.
Python · Cell 108View code
# Generate a result-grounded portfolio summary.
portfolio_summary = (
'# Stellar classification: a reproducible machine-learning case study\n\n'
'I rebuilt a six-class classification workflow around 240 labelled stellar records. '
'The analysis audits the data, visualises physical relationships, and compares Naive Bayes, '
'KNN, and a decision tree with a no-skill baseline. Learned preprocessing stays inside '
'training folds; nested cross-validation selects the model family before a shared final test.\n\n'
f'The selected {winner_name} model classified {correct_count} of {len(y_test)} held-out records '
f'correctly ({selected_accuracy:.2%}), with macro F1 {selected_f1:.4f}. '
f'The approximate 95% Wilson accuracy interval is {accuracy_low:.2%} to {accuracy_high:.2%}. '
'These are results on a small curated dataset and do not establish accuracy on an independent survey.\n\n'
'The project demonstrates data auditing, reproducible Python, comparative modelling, '
'uncertainty reporting, explainable rules, and decision-focused interpretation. '
'Potential extensions include catalogue triage, expert review tools, and research education. '
'Operational or financial claims would require representative data and a measured pilot.\n'
)
print(portfolio_summary)
# Your note:
# Stellar classification: a reproducible machine-learning case study I rebuilt a six-class classification workflow around 240 labelled stellar records. The analysis audits the data, visualises physical relationships, and compares Naive Bayes, KNN, and a decision tree with a no-skill baseline. Learned preprocessing stays inside training folds; nested cross-validation selects the model family before a shared final test. The selected Decision tree model classified 48 of 48 held-out records correctly (100.00%), with macro F1 1.0000. The approximate 95% Wilson accuracy interval is 92.59% to 100.00%. These are results on a small curated dataset and do not establish accuracy on an independent survey. The project demonstrates data auditing, reproducible Python, comparative modelling, uncertainty reporting, explainable rules, and decision-focused interpretation. Potential extensions include catalogue triage, expert review tools, and research education. Operational or financial claims would require representative data and a measured pilot.
Step 109 · Save the short website summary
Markdown is easy to edit and integrate into a portfolio content system.
Python · Cell 109View code
# Save the short website summary.
(OUTPUT_DIR / 'portfolio_summary.md').write_text(portfolio_summary, encoding='utf-8')
# Your note:
1049
17. Learning exercises and future work
Add your explanation in a nearby Markdown cell or in the # Your note: area. Use training data only for these experiments. Because the current holdout results have been viewed, a genuinely new final claim would need a fresh external test.
- Explain scaling. Why would raw luminosity dominate an ordinary Euclidean KNN distance? Compare a deliberately unscaled KNN using training-only CV; do not claim a new final-test improvement.
- Interpret one confusion. If two classes are confused, which measurement ranges overlap? Use training records to investigate hypotheses.
- Inspect the assumptions. Why does a large Shapiro–Wilk p-value not prove normality? Why does a correlation matrix not establish conditional independence?
- Extend the inputs. Add colour and spectral class through a
ColumnTransformerwith one-hot encoding for nominal text. Audit how these labels were obtained and whether they would be available at prediction time. Do not impose arbitrary integer distances on colour names. - Study feature availability. If radius or luminosity is expensive to obtain, compare a smaller feature set in development CV. Use a nested selection procedure if choosing features from scores, and document the acquisition cost.
- Improve scientific validation. Obtain a verified catalogue with identifiers and uncertainties, align class definitions with a specialist, and split by physical source and instrument where appropriate.
- Validate a review policy. Use separate development/calibration data, define the cost of false positives and missed rare sources, and measure recall and contamination on a new test.
- Measure value. Run a small pilot, record actual review time and corrections, and compare total costs with the manual process.
References and provenance notes
The source link below was inherited from the original submission; the locally supplied CSV is the file actually analysed. Its catalogue lineage and reuse terms are not contained in the upload. Verify the original data terms before redistributing it publicly. Method and application references were checked on 4 October 2026.
| Reference | Supports |
|---|---|
| Kaggle: Star dataset to predict star types | Source attribution in the original notebook; exact observational provenance remains unverified |
| scikit-learn: Common pitfalls | Consistent preprocessing and prevention of data leakage |
| scikit-learn: Naive Bayes | Conditional independence, Gaussian likelihoods, and probability-estimation caveats |
| scikit-learn: Nearest neighbours | Neighbour-based classification |
| scikit-learn: Classification metrics | Accuracy, per-class metrics, macro averages, ROC AUC, and log loss |
| scikit-learn: Nested versus non-nested CV | Separation of tuning and validation |
| SciPy: Shapiro–Wilk | The normality null hypothesis and test interpretation |
| NASA: Types of stars | Real stellar categories and their relationship to main-sequence stars |
| Prša et al. (2016), IAU nominal solar values | Nominal solar conversion constants used in the optional physics diagnostic |
| Creevey et al. (2023), Gaia Apsis methods | Real astronomical classification and parameter-estimation context |
| SALT: About the organisation | South African research infrastructure and education context |
Reproducibility checklist: input checksum recorded; no exact duplicate rows; row-wise class mapping verified; one fixed stratified holdout; fold-specific preprocessing; training-only tuning/selection; independent metrics per model; saved predictions and settings; fitted-pipeline reload verified.