Skip to the analysis
GARETH GRANTTECHNOLOGY · DATA · INNOVATION Back to projects
Back to all projects

Portfolio case study · Classification · Python

Stellar classification.From data to decisions.

A transparent, reproducible workflow for comparing classifiers, interpreting evidence and connecting an astronomy dataset to practical decisions.

Gareth Grant · 4 October 2026 · Executed code, charts and interpretation

240labelled records
6dataset classes
109explained code cells
48 / 48selected-model test

Evidence note: A decision tree was selected with training-only nested cross-validation. Held-out accuracy was 100%; the approximate 95% Wilson interval was 92.6–100%. The small curated sample limits claims about new surveys.

Table of contents
GARETH GRANTDATA ANALYTICS · MACHINE LEARNING

Portfolio case study · Python

Stellar classification.
From data to decisions.

A transparent, reproducible workflow for comparing classifiers, interpreting evidence, and connecting an astronomy dataset to practical decisions.

Gareth Grant · 4 October 2026 · Executed code, charts, and interpretation

240labelled records
6dataset classes
109small, explained code cells
48/48selected model · final test

Decision tree selected using training nested cross-validation. Held-out accuracy 100.0%; approximate 95% Wilson interval 92.6%–100.0%. A small curated sample provides limited evidence about an independent survey.

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

  1. Put this notebook beside Stars.csv, install the packages in requirements.txt, and select a Python kernel.
  2. Restart the kernel and run the cells from top to bottom. Outputs are already included for readers.
  3. Each code cell does one task. Read its explanation, run it, and add your own comment at # Your note:.
  4. 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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
Plot training class counts
Plot training class counts

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: 
Result
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: 
Result
Plot a Hertzsprung–Russell style diagram
Plot a Hertzsprung–Russell style diagram

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: 
Result
Plot temperature against visual magnitude
Plot temperature against visual magnitude

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: 
Result
Inspect feature relationships
Inspect feature relationships

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: 
Result
Compare transformed distributions
Compare transformed distributions

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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
Inspect KNN tuning
Inspect KNN tuning

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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
Plot model accuracy and macro F1
Plot model accuracy and macro F1

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: 
Result
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: 
Result
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: 
Result
Show all learned-model confusion matrices
Show all learned-model confusion matrices

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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
Plot one-vs-rest ROC curves
Plot one-vs-rest ROC curves

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: 
Result
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: 
Result
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: 
Result
|--- 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: 
Result
Draw the learned tree
Draw the learned tree

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: 
Result
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: 
Result
Plot exploratory importance
Plot exploratory importance

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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
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: 
Result
Visualise the assumed workload
Visualise the assumed workload

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: 
Result
['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: 
Result
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: 
Result
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: 
Result
# 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: 
Result
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.

  1. 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.
  2. Interpret one confusion. If two classes are confused, which measurement ranges overlap? Use training records to investigate hypotheses.
  3. Inspect the assumptions. Why does a large Shapiro–Wilk p-value not prove normality? Why does a correlation matrix not establish conditional independence?
  4. Extend the inputs. Add colour and spectral class through a ColumnTransformer with 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.

Gareth Grant · Reproducible analysis, documented assumptions, and decisions grounded in evidence.
Code copied
Code copied to clipboard