Explainable Brain Tumor Classifier
Train and audit a four-class brain MRI model with Integrated Gradients.
Introduction
30 Second Summary
Medical images can contain patterns that are difficult to judge at a glance. A confident result can still conceal important mistakes.
In this project, you will build a local four-class brain MRI research classifier using transfer learning. Its audit keeps class-specific errors visible without turning a research result into a diagnosis.
What You'll Build
You will run one local script to watch arbitrary MRI labels become trained predictions in a visible before-and-after grid.
By the end of this project, you'll have:
- Compare predictions from the untrained classifier head with trained results for the same held-out MRI slices.
- Audit every class using a confusion matrix with precision and recall for glioma, meningioma, no tumor, and pituitary images.
- Explain one prediction using an Integrated Gradients overlay that highlights influential pixels without claiming tumor localization.
- Secret Mission: Write a concise model card that names the weakest class. Use the audit evidence to justify one testable next experiment.
Are there any prerequisites?
You need a Windows computer with internet access plus an existing local Python and Jupyter setup. The guided compatibility checks make previous deep-learning experience unnecessary.
Before We Start
This first checkpoint defines your four-class BRISC 2025 MRI classifier as an educational research prototype for studying evaluation and explainability. Its outputs cannot diagnose patients or guide medical decisions.
Set Up the Windows Project
A reproducible classifier needs the same package versions every time it runs. It also needs a known dataset layout before any model code can load an image.
You’ll use Windows PowerShell to isolate a compatible Python installation. You’ll also place BRISC 2025 into the folder structure that the audit pipeline expects.
In this step, get ready to:
- Prepare a local project folder with 64-bit Python version 3.11 through 3.13.
- Install the pinned machine-learning packages inside .venv.
- Extract the published BRISC training split plus its test split into data\brisc2025.
Create the project environment
A virtual environment gives this project its own package installation. That isolation prevents another Python project from changing the versions your classifier uses.
- Press the Windows key to open Windows search.
- Type PowerShell.
- Press Enter to open Windows PowerShell.
- Create the brain-tumor-audit folder on your Desktop by running these commands:
Set-Location -Path "$env:USERPROFILE\Desktop"
New-Item -ItemType Directory -Path brain-tumor-audit
Set-Location brain-tumor-audit
What do these commands do?
- The first command moves PowerShell to your Desktop so the project is easy to find.
- The second command creates the brain-tumor-audit folder.
- The final command makes brain-tumor-audit the current PowerShell location.
You should see PowerShell list the new directory. The prompt path should now end with Desktop\brain-tumor-audit.
Before you check, which Python version do you expect this computer to use?
- Check the Python version plus its architecture by running:
python --version
python -c "import struct; print(struct.calcsize('P') * 8)"
What do these checks prove?
- The first command prints the Python version connected to the python command.
- The second command measures the interpreter’s pointer size. An output of 64 confirms a 64-bit Python installation.
✔️ I see a supported 64-bit version
Your interpreter is compatible when the version is between 3.11 and 3.13. The architecture check should show 64.
- Continue with your existing Python installation.
ⓧ I see another version or 32
This project needs 64-bit Python from 3.11 through 3.13. Install a current 64-bit Python 3.13 release before creating the environment.
- Open the official Python Releases for Windows page.
- Download the current Python 3.13 release with Windows installer (64-bit).
- Run the downloaded installer.
- Enable Add Python to PATH during installation.
- Complete the Python installation.
- Close the current PowerShell window.
- Press the Windows key.
- Type PowerShell.
- Press Enter.
- Return to the brain-tumor-audit folder by running:
Set-Location -Path "$env:USERPROFILE\Desktop\brain-tumor-audit"
What does this command do?
This command restores the project location in the new PowerShell session. Your next files are created inside brain-tumor-audit.
- Repeat the compatibility checks by running:
python --version
python -c "import struct; print(struct.calcsize('P') * 8)"
What should I see?
The version should now begin with 3.13. The architecture check should print 64.
ⓧ Command not found
Windows cannot currently find a Python installation through the python command. Install a current 64-bit Python 3.13 release from Python.org.
- Open the official Python Releases for Windows page.
- Download the current Python 3.13 release with Windows installer (64-bit).
- Run the downloaded installer.
- Enable Add Python to PATH during installation.
- Complete the Python installation.
- Close the current PowerShell window.
- Press the Windows key.
- Type PowerShell.
- Press Enter.
- Return to the brain-tumor-audit folder by running:
Set-Location -Path "$env:USERPROFILE\Desktop\brain-tumor-audit"
What does this command do?
This command returns PowerShell to the project folder after the new installation. The rest of the setup stays contained in brain-tumor-audit.
- Repeat the compatibility checks by running:
python --version
python -c "import struct; print(struct.calcsize('P') * 8)"
What should I see?
The version should now begin with 3.13. The architecture check should print 64.
- Create the isolated .venv environment by running:
python -m venv .venv
What does this command do?
Python creates a .venv folder containing a separate interpreter plus its own package installer. Packages installed here stay local to this project.
- Activate the new environment by running:
.\.venv\Scripts\Activate.ps1
What does activation change?
Activation points the python command at the interpreter inside .venv. Future package installations now belong to this project.
You should see (.venv) at the start of the PowerShell prompt. That prefix confirms the isolated environment is active.
Activation blocked?
If PowerShell blocks the activation script, check whether your computer has an organization-managed execution policy. Avoid changing a managed security policy without approval.
Confirm that the command includes both leading characters in .\ plus the exact .venv folder name.
Ask for help with the policy or path shown on your computer: help me activate this Windows virtual environment
Install the pinned packages
The project pins TensorFlow 2.21.0, Keras 3.15.1, plus Matplotlib 3.11.2. Native Windows runs this TensorFlow setup on the CPU.
- Press the Windows key to open Windows search.
- Type Notepad.
- Press Enter to open Notepad.
- Paste the following package list into the blank document:
tensorflow==2.21.0
keras==3.15.1
matplotlib==3.11.2
What does this file control?
Each line pins one dependency to an exact version. Anyone using this file installs the same TensorFlow, Keras, plus Matplotlib combination.
- Click File in Notepad.
- Click Save As.
- Navigate to the brain-tumor-audit folder on your Desktop.
- Enter requirements.txt in the File name field.
- Click Save.
✔️ Awesome, I've got everything!
Your pinned package list is saved inside brain-tumor-audit.
ⓧ I'd like to double check the full code
- Compare your complete requirements.txt file with this reference:
tensorflow==2.21.0
keras==3.15.1
matplotlib==3.11.2
- Switch back to the PowerShell window from earlier.
- Upgrade the package installer inside .venv by running:
python -m pip install --upgrade pip
What does this command do?
This updates pip inside the active virtual environment. A current installer can resolve the pinned Windows packages correctly.
The dependency installation can take several minutes while large packages download. A quiet stretch in the terminal is normal during this first installation.
- Install the pinned project dependencies by running:
python -m pip install -r requirements.txt
What does this command install?
The command reads each version from requirements.txt. It installs the packages into the active .venv environment.
You should see the installation finish without an unresolved dependency. Your environment now contains tensorflow==2.21.0, keras==3.15.1, plus matplotlib==3.11.2.
Package installation failed?
Check that the PowerShell prompt still begins with (.venv). Reactivate the environment if that prefix is missing.
Confirm that requirements.txt is saved directly inside brain-tumor-audit. A copy saved in Documents or Downloads cannot be found from the project folder.
A package compatibility failure usually points to an unsupported Python version or architecture. Help me diagnose my pinned package installation
Download and verify BRISC 2025
The published folder names act as labeled image folders for the classifier. Keeping the published training split separate from its test split protects the final audit from training-data leakage.
- Switch back to the PowerShell window from earlier.
- Create the dataset destination inside brain-tumor-audit by running:
New-Item -Path data -ItemType Directory
What does this command do?
This creates the local data folder that receives the extracted archive. The finished classification files belong under data\brisc2025.
The dataset is hosted on Kaggle. Kaggle may ask you to sign in before the download starts.
The download is approximately 276.58 MB. A short wait here is normal.
- Open the official BRISC 2025 Kaggle page.
- Complete the Kaggle sign-in flow if prompted.
- Click Download on the dataset page.
- Wait for the archive to finish downloading.
- Press Windows+E to open File Explorer.
- Navigate to your downloaded BRISC archive.
- Right-click the downloaded archive.
- Select Extract All....
- Use the destination picker to choose the data folder inside brain-tumor-audit.
- Click Extract to unpack the dataset.
Why keep the published split?
The BRISC archive provides separate train plus test sources. Later steps create a validation subset only from the published training source.
The dataset cannot guarantee complete subject-level independence. Your results remain a small educational experiment with limited generalization claims.
Before you run the final check, do you expect the package installer to download everything again?
- Switch back to the PowerShell window from earlier.
- Verify the active Python version plus the installed requirements by running:
python --version
python -m pip install -r requirements.txt
What should this final check confirm?
The first line should show a supported Python version from 3.11 through 3.13. The package installer should report that the pinned dependencies are already installed.
Your environment check should finish without reinstalling a different package version. That result proves .venv can reproduce the dependency set.
- Return to the File Explorer window from earlier.
- Navigate to data\brisc2025\classification_task\train.
You should see glioma, meningioma, no_tumor, plus pituitary folders inside the published training split.
- Navigate to data\brisc2025\classification_task\test.
You should see the same four class folders inside the published test split. The two verified sources are now ready for a non-overlapping quick experiment.
Class folders missing?
Check for an extra archive folder between data and brisc2025. Move the inner brisc2025 folder directly under data if the archive created another wrapper folder.
Confirm that you downloaded the full BRISC 2025 dataset. The expected classification source includes both train plus test.
Share the folder path without uploading any MRI files: help me fix my BRISC extraction path
Keep the scope research-only
BRISC 2025 is intended for research plus methodological development. This local classifier remains an educational prototype that must not guide diagnosis or any medical decision.
Your Windows environment now has the pinned packages plus the published BRISC folder structure. Next, you’ll run an untrained classifier head to see what a model produces before it learns from the MRI labels.
Run the Untrained Classifier
Your isolated environment is ready. The labeled BRISC 2025 training and test folders give the experiment a known source for every MRI slice.
A classifier architecture alone cannot recognize the four BRISC classes. This step runs a randomly initialized four-class head so you can see its arbitrary logits before any learning happens.
In this step, get ready to:
- Create balanced training, validation, and test subsets from separate BRISC sources.
- Load the subsets as ordered Keras image datasets.
- Run a frozen MobileNetV2 model with a random four-logit head.
Create balanced quick subsets
A balanced subset gives every class the same number of examples. Separate source ranges keep the training images out of validation while the published test folder remains held out.
- Create brain_tumor_audit.py inside your brain-tumor-audit folder using your local Python editor.
- Add the imports and experiment configuration by copying this code into brain_tumor_audit.py:
import os
import shutil
from pathlib import Path
os.environ["KERAS_BACKEND"] = "tensorflow"
import keras
import matplotlib.pyplot as plt
import tensorflow as tf
SEED = 123
IMAGE_SIZE = (160, 160)
BATCH_SIZE = 16
EPOCHS = 3
TRAIN_PER_CLASS = 150
VALIDATION_PER_CLASS = 50
TEST_PER_CLASS = 50
CLASS_NAMES = ["glioma", "meningioma", "no_tumor", "pituitary"]
DATA_ROOT = Path("data/brisc2025/classification_task")
QUICK_ROOT = Path("quick_data")
OUTPUT_DIR = Path("outputs")
keras.utils.set_random_seed(SEED)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
What does this setup do?
- The backend setting connects Keras to TensorFlow before Keras is imported.
- The subset constants select 600 training images, 200 validation images, and 200 test images.
- The ordered CLASS_NAMES list gives every loader and prediction the same label mapping.
- The fixed SEED makes random operations reproducible.
- Save brain_tumor_audit.py.
- Check the initial setup from the activated PowerShell window by running:
python brain_tumor_audit.py
What does this check prove?
Python imports the installed packages and runs the top-level configuration. You should return to the PowerShell prompt without a traceback.
You should also see an outputs folder beside brain_tumor_audit.py in your editor's file sidebar.
Seeing an import error?
Check that your PowerShell prompt still shows the activated .venv environment. The script must use the environment where the project packages were installed.
Confirm that brain_tumor_audit.py is saved inside brain-tumor-audit.
help me debug the first run of brain_tumor_audit.py
- Add the subset-copying function below the OUTPUT_DIR.mkdir line:
def make_balanced_subset(source_dir, target_dir, per_class, start_index=0):
if target_dir.exists():
return
target_dir.mkdir(parents=True, exist_ok=True)
for class_name in CLASS_NAMES:
source_class = source_dir / class_name
target_class = target_dir / class_name
if not source_class.exists():
raise FileNotFoundError(
f"Missing class folder: {source_class}. Check the BRISC extraction path."
)
image_paths = sorted(source_class.glob("*.jpg"))
selected = image_paths[start_index : start_index + per_class]
if len(selected) != per_class:
raise ValueError(
f"Expected {per_class} images for {class_name}, found {len(selected)}."
)
target_class.mkdir(parents=True, exist_ok=True)
for image_path in selected:
shutil.copy2(image_path, target_class / image_path.name)
How are the subsets protected?
- The function skips an existing target directory so repeated runs do not recopy the subset.
- Sorting the image paths makes each selected range reproducible.
- The start_index value lets validation begin after the training range.
- The folder and image-count checks stop the experiment when the extracted data does not match the expected layout.
- Add the initial main() function at the bottom of brain_tumor_audit.py:
def main():
train_source = DATA_ROOT / "train"
test_source = DATA_ROOT / "test"
if not train_source.exists() or not test_source.exists():
raise FileNotFoundError(
"BRISC classification folders were not found. Expected "
"data/brisc2025/classification_task/train and test."
)
make_balanced_subset(
train_source,
QUICK_ROOT / "train",
TRAIN_PER_CLASS,
start_index=0,
)
make_balanced_subset(
train_source,
QUICK_ROOT / "validation",
VALIDATION_PER_CLASS,
start_index=TRAIN_PER_CLASS,
)
make_balanced_subset(
test_source,
QUICK_ROOT / "test",
TEST_PER_CLASS,
start_index=0,
)
if __name__ == "__main__":
main()
What does main create?
- The training subset copies the first 150 sorted images from every published training class.
- The validation subset copies the next 50 sorted images from every published training class.
- The test subset copies 50 sorted images from every published test class.
- The source checks keep an incorrect extraction path from producing empty experiment folders.
- Save brain_tumor_audit.py.
- Create the three balanced subsets from the activated PowerShell window by running:
python brain_tumor_audit.py
What does this run create?
The script copies 1,000 images into quick_data. It leaves the original BRISC folders unchanged.
You should return to the PowerShell prompt after the local copies finish.
- Expand quick_data in your editor's file sidebar.
- Confirm that train, validation, and test each contain the four class folders.
Your quick experiment now preserves separate training, validation, and published test sources. That separation keeps the final prediction grid independent from the images used to fit the model later.
Missing a quick-data folder?
Check that data/brisc2025/classification_task contains both source splits. Each split needs the exact four class folder names used by CLASS_NAMES.
Delete quick_data before rerunning if a previous partial copy left an incomplete target directory.
help me fix the BRISC subset creation
Load the image datasets
A directory dataset converts each class folder into labeled image batches. The explicit class order ensures that label zero always means glioma while label three always means pituitary.
- Insert the dataset loader immediately above def main()::
def load_dataset(directory, shuffle):
return keras.utils.image_dataset_from_directory(
directory,
labels="inferred",
label_mode="int",
class_names=CLASS_NAMES,
color_mode="rgb",
batch_size=BATCH_SIZE,
image_size=IMAGE_SIZE,
shuffle=shuffle,
seed=SEED,
)
How are the images loaded?
- Folder names provide integer labels in the exact order defined by CLASS_NAMES.
- Every image becomes an RGB tensor resized to 160 by 160 pixels.
- Each dataset yields batches of 16 images.
- The shuffle argument lets training randomize examples while validation and test order stays stable.
- Find the final make_balanced_subset call inside main().
- Add these dataset-loading lines directly below that call:
train_ds = load_dataset(QUICK_ROOT / "train", shuffle=True)
validation_ds = load_dataset(QUICK_ROOT / "validation", shuffle=False)
test_ds = load_dataset(QUICK_ROOT / "test", shuffle=False)
Why load three datasets?
The script assigns one job to each split. Training supplies examples for learning later.
Validation measures training progress. Test supplies the held-out images used by this prediction grid.
- Save brain_tumor_audit.py.
- Verify the three dataset sizes from the activated PowerShell window by running:
python brain_tumor_audit.py
What should you see?
Keras prints three file-count messages while it builds the datasets. The counts should be 600 for training, 200 for validation, and 200 for test.
Those totals prove that every split contains the planned number of images across four balanced classes.
Seeing an unexpected image count?
Check that each quick_data split contains only its four class folders. Extra nested folders can change directory-based loading.
Remove quick_data before rerunning if you changed any subset constant after the folders were first created.
help me debug the Keras dataset counts
Build and run the untrained model
Transfer learning starts with visual features learned by a pretrained model. The frozen ImageNet base supplies those features while the new four-unit head begins with random weights.
- Insert the model-building function immediately above def main()::
def build_model():
base_model = keras.applications.MobileNetV2(
input_shape=IMAGE_SIZE + (3,),
include_top=False,
weights="imagenet",
pooling="avg",
)
base_model.trainable = False
inputs = keras.Input(shape=IMAGE_SIZE + (3,))
x = keras.applications.mobilenet_v2.preprocess_input(inputs)
x = base_model(x, training=False)
outputs = keras.layers.Dense(len(CLASS_NAMES))(x)
model = keras.Model(inputs=inputs, outputs=outputs, name="brisc_mri_classifier")
model.compile(
optimizer=keras.optimizers.Adam(learning_rate=0.001),
loss=keras.losses.SparseCategoricalCrossentropy(from_logits=True),
metrics=["accuracy"],
)
return model
How is the classifier assembled?
- MobileNetV2 receives three-channel images with the configured 160-by-160 size.
- Removing the original top leaves the pretrained feature extractor available for a new task.
- Freezing base_model protects its pretrained weights during later head training.
- The final dense layer returns one raw logit for each BRISC class.
- Find the test_ds assignment inside main().
- Add these lines directly below it:
sample_images, sample_labels = next(iter(test_ds))
model = build_model()
What do these lines prepare?
The first line takes one held-out test batch. The second line creates the frozen feature extractor and its random four-logit head.
The first model build may download pretrained weights. Allow a few minutes if you see download progress in PowerShell.
- Save brain_tumor_audit.py.
- Confirm that the model initializes from the activated PowerShell window by running:
python brain_tumor_audit.py
What does this run prove?
The script loads all three datasets and builds brisc_mri_classifier without an exception. PowerShell returns to the prompt after initialization finishes.
A download progress display can appear when the pretrained weights are not already cached.
Model initialization failed?
Check your internet connection if the pretrained weight download cannot complete. Rerunning the script resumes the setup once the connection is available.
Compare the configured image shape with IMAGE_SIZE + (3,). MobileNetV2 expects three image channels in this project.
help me debug MobileNetV2 initialization
- Insert the prediction-grid function immediately above def main()::
def save_prediction_grid(images, labels, logits, output_path, title):
probabilities = tf.nn.softmax(logits, axis=-1)
predictions = tf.argmax(
probabilities, axis=-1, output_type=tf.int32
).numpy()
confidences = tf.reduce_max(probabilities, axis=-1).numpy()
fig, axes = plt.subplots(2, 4, figsize=(12, 6))
count = min(8, int(images.shape[0]))
for index, ax in enumerate(axes.flat):
ax.axis("off")
if index >= count:
continue
true_index = int(labels[index].numpy())
ax.imshow(images[index].numpy().astype("uint8"))
ax.set_title(
f"True: {CLASS_NAMES[true_index]}\n"
f"Pred: {CLASS_NAMES[predictions[index]]} "
f"({confidences[index]:.2f})"
)
fig.suptitle(title)
fig.tight_layout()
fig.savefig(output_path, dpi=160, bbox_inches="tight")
How does the grid expose the predictions?
- Softmax converts the four logits into class probabilities that sum to one.
- The highest probability selects a predicted class and supplies its confidence value.
- The two-by-four figure pairs each held-out MRI with its true label and predicted label.
- The final line saves the visible evidence to the requested PNG path.
- Find the model = build_model() line inside main().
- Add the untrained prediction call directly below it:
untrained_logits = model(sample_images, training=False)
save_prediction_grid(
sample_images,
sample_labels,
untrained_logits,
OUTPUT_DIR / "untrained_predictions.png",
"Before training: arbitrary classifier-head predictions",
)
Why run the model before training?
The held-out batch passes through the frozen base and random dense head. Saving these predictions creates a baseline that later training can be compared against.
The values remain research outputs from an untrained classifier. They carry no diagnostic meaning.
Before you run the complete baseline, do you expect the predicted labels to follow the true labels consistently?
- Save brain_tumor_audit.py.
- Generate the untrained prediction grid from the activated PowerShell window by running:
python brain_tumor_audit.py
What did the baseline reveal?
The script saves outputs/untrained_predictions.png after evaluating one held-out batch. The predicted labels and confidence values are arbitrary because the four-class head still has random weights.
That visible shortfall is the intended result. You now have evidence that architecture alone does not recognize the BRISC classes.
- Press the Windows key to open Windows search.
- Type File Explorer and press Enter.
- Navigate to the outputs folder inside brain-tumor-audit.
- Double-click untrained_predictions.png.
You should see up to eight MRI slices in a two-by-four grid. Each visible slice shows its true class beside an arbitrary predicted class and confidence value.
No prediction grid?
Check the PowerShell traceback for the first failing line. A dataset problem usually points to a class folder while a plotting problem points to the prediction-grid function.
Confirm that the output path uses OUTPUT_DIR / "untrained_predictions.png" exactly. The configured output directory already exists.
help me fix the missing untrained prediction grid
✔️ Awesome, I've got everything!
Great work. Your saved grid now captures the arbitrary behavior of the untrained classifier head.
ⓧ I'd like to double check the full code
Compare your current brain_tumor_audit.py with this complete baseline script:
import os
import shutil
from pathlib import Path
os.environ["KERAS_BACKEND"] = "tensorflow"
import keras
import matplotlib.pyplot as plt
import tensorflow as tf
SEED = 123
IMAGE_SIZE = (160, 160)
BATCH_SIZE = 16
EPOCHS = 3
TRAIN_PER_CLASS = 150
VALIDATION_PER_CLASS = 50
TEST_PER_CLASS = 50
CLASS_NAMES = ["glioma", "meningioma", "no_tumor", "pituitary"]
DATA_ROOT = Path("data/brisc2025/classification_task")
QUICK_ROOT = Path("quick_data")
OUTPUT_DIR = Path("outputs")
keras.utils.set_random_seed(SEED)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
def make_balanced_subset(source_dir, target_dir, per_class, start_index=0):
if target_dir.exists():
return
target_dir.mkdir(parents=True, exist_ok=True)
for class_name in CLASS_NAMES:
source_class = source_dir / class_name
target_class = target_dir / class_name
if not source_class.exists():
raise FileNotFoundError(
f"Missing class folder: {source_class}. Check the BRISC extraction path."
)
image_paths = sorted(source_class.glob("*.jpg"))
selected = image_paths[start_index : start_index + per_class]
if len(selected) != per_class:
raise ValueError(
f"Expected {per_class} images for {class_name}, found {len(selected)}."
)
target_class.mkdir(parents=True, exist_ok=True)
for image_path in selected:
shutil.copy2(image_path, target_class / image_path.name)
def load_dataset(directory, shuffle):
return keras.utils.image_dataset_from_directory(
directory,
labels="inferred",
label_mode="int",
class_names=CLASS_NAMES,
color_mode="rgb",
batch_size=BATCH_SIZE,
image_size=IMAGE_SIZE,
shuffle=shuffle,
seed=SEED,
)
def build_model():
base_model = keras.applications.MobileNetV2(
input_shape=IMAGE_SIZE + (3,),
include_top=False,
weights="imagenet",
pooling="avg",
)
base_model.trainable = False
inputs = keras.Input(shape=IMAGE_SIZE + (3,))
x = keras.applications.mobilenet_v2.preprocess_input(inputs)
x = base_model(x, training=False)
outputs = keras.layers.Dense(len(CLASS_NAMES))(x)
model = keras.Model(inputs=inputs, outputs=outputs, name="brisc_mri_classifier")
model.compile(
optimizer=keras.optimizers.Adam(learning_rate=0.001),
loss=keras.losses.SparseCategoricalCrossentropy(from_logits=True),
metrics=["accuracy"],
)
return model
def save_prediction_grid(images, labels, logits, output_path, title):
probabilities = tf.nn.softmax(logits, axis=-1)
predictions = tf.argmax(
probabilities, axis=-1, output_type=tf.int32
).numpy()
confidences = tf.reduce_max(probabilities, axis=-1).numpy()
fig, axes = plt.subplots(2, 4, figsize=(12, 6))
count = min(8, int(images.shape[0]))
for index, ax in enumerate(axes.flat):
ax.axis("off")
if index >= count:
continue
true_index = int(labels[index].numpy())
ax.imshow(images[index].numpy().astype("uint8"))
ax.set_title(
f"True: {CLASS_NAMES[true_index]}\n"
f"Pred: {CLASS_NAMES[predictions[index]]} "
f"({confidences[index]:.2f})"
)
fig.suptitle(title)
fig.tight_layout()
fig.savefig(output_path, dpi=160, bbox_inches="tight")
def main():
train_source = DATA_ROOT / "train"
test_source = DATA_ROOT / "test"
if not train_source.exists() or not test_source.exists():
raise FileNotFoundError(
"BRISC classification folders were not found. Expected "
"data/brisc2025/classification_task/train and test."
)
make_balanced_subset(
train_source,
QUICK_ROOT / "train",
TRAIN_PER_CLASS,
start_index=0,
)
make_balanced_subset(
train_source,
QUICK_ROOT / "validation",
VALIDATION_PER_CLASS,
start_index=TRAIN_PER_CLASS,
)
make_balanced_subset(
test_source,
QUICK_ROOT / "test",
TEST_PER_CLASS,
start_index=0,
)
train_ds = load_dataset(QUICK_ROOT / "train", shuffle=True)
validation_ds = load_dataset(QUICK_ROOT / "validation", shuffle=False)
test_ds = load_dataset(QUICK_ROOT / "test", shuffle=False)
sample_images, sample_labels = next(iter(test_ds))
model = build_model()
untrained_logits = model(sample_images, training=False)
save_prediction_grid(
sample_images,
sample_labels,
untrained_logits,
OUTPUT_DIR / "untrained_predictions.png",
"Before training: arbitrary classifier-head predictions",
)
if __name__ == "__main__":
main()
How to use this reference
Compare the function names, constants, indentation, and main() sequence with your saved file. This version stops after creating the untrained prediction grid.
You have made the classifier's missing learning visible. Next, you will train its four-class head and compare the new predictions with this baseline.
Train the Transfer-Learning Head
Your untrained prediction grid proved that the frozen MobileNetV2 architecture can process BRISC 2025 images. Its new classifier head still assigns arbitrary labels because it has not learned from the training subset.
Now you will use transfer learning to map the pretrained image features to the four BRISC classes. Only the Dense head learns while the MobileNetV2 base stays frozen.
In this step, get ready to:
- Compile the logits model for four-class training.
- Train the classifier head for three epochs.
- Save the trained model and generate a prediction grid.
Compile and train the classifier head
The model produces logits, which are raw class scores. Keras needs a loss function that interprets those scores correctly during training.
- Locate build_model() in brain_tumor_audit.py.
- Add this compilation block below the line that creates model and above return model:
model.compile(
optimizer=keras.optimizers.Adam(learning_rate=0.001),
loss=keras.losses.SparseCategoricalCrossentropy(from_logits=True),
metrics=["accuracy"],
)
What Does This Training Setup Do?
- Adam adjusts the trainable Dense-head weights using a learning rate of 0.001.
- Sparse categorical cross-entropy compares each integer class label with the four output scores.
- The from_logits=True setting tells the loss function that the Dense layer returns raw scores.
- The accuracy metric reports the share of labels predicted correctly during each epoch.
Compilation defines how the head learns. The next block feeds the training subset into that process while the separate validation subset measures performance after each epoch.
- Locate the save_prediction_grid() call in main() that creates outputs/untrained_predictions.png.
- Add this training block immediately below that call:
model.fit(
train_ds,
validation_data=validation_ds,
epochs=EPOCHS,
verbose=2,
)
How Does Training Stay Focused?
- The train_ds dataset supplies the images that update the classifier head.
- The validation_ds dataset measures the model on separate images without updating its weights.
- The EPOCHS constant limits training to three passes through the balanced training subset.
- The existing base_model.trainable = False setting keeps the pretrained feature extractor frozen.
- Save brain_tumor_audit.py.
Training on a Windows CPU can take several minutes. Give each epoch time to finish before judging whether the script is still running.
Before you run the script, which measurements do you expect to change across the three epoch summaries?
- Train the classifier head by running this command:
python brain_tumor_audit.py
What Should You See?
You will see three epoch summaries in PowerShell. Each summary reports measurements for the training subset and the separate validation subset.
The values can fluctuate because this is a small research subset. The completed third epoch proves that the trainable head received all three passes through the data.
Training Did Not Reach the Third Epoch?
Check that model.fit() sits inside main() at the same indentation level as the untrained prediction-grid call.
Confirm that validation_data refers to validation_ds. A misspelled dataset name stops training before the epoch summaries appear.
help me diagnose why my three-epoch Keras training run stopped
Save and compare the trained predictions
Training changes the classifier-head weights in memory. Saving the model preserves those learned weights after the script exits.
A second grid runs the same held-out batch through the trained model. This creates a direct visual comparison with the arbitrary pre-training predictions.
- Locate the closing parenthesis of model.fit() in main().
- Add this block immediately below it:
model.save(OUTPUT_DIR / "brain_tumor_classifier.keras")
trained_logits = model(sample_images, training=False)
save_prediction_grid(
sample_images,
sample_labels,
trained_logits,
OUTPUT_DIR / "trained_predictions.png",
"After training: learned BRISC quick-subset predictions",
)
What Does This Code Save?
- The model.save() call writes the trained architecture and weights to outputs/brain_tumor_classifier.keras.
- The trained_logits variable holds new scores for the same held-out images used before training.
- The second save_prediction_grid() call writes the learned predictions to outputs/trained_predictions.png.
- The grid still shows true labels beside predicted labels so individual mistakes remain visible.
- Save brain_tumor_audit.py.
Before you run the completed training flow, how do you expect the trained grid to differ from the untrained grid?
- Train the head and create both output files by running this command:
python brain_tumor_audit.py
What Should the Completed Run Produce?
You will see three epoch summaries before the script finishes. The outputs folder will contain the saved model and the trained prediction grid.
That is the key change in this step. Your four-class head now uses patterns learned from quick_data/train while its progress was checked against quick_data/validation.
- Navigate to the outputs folder inside brain-tumor-audit in File Explorer.
- Double-click trained_predictions.png.
- Open untrained_predictions.png from the same folder for comparison.
You will see the same held-out MRI slices with predictions produced after three training epochs. Some labels may still be wrong because this quick experiment is a research prototype.
What Does the Comparison Prove?
The trained grid proves that the head now applies weights learned from the labeled BRISC folders. It does not establish clinical validity or guarantee correct predictions.
Confidence values alone cannot reveal whether one class is being ignored. The next step measures that behavior across the full held-out quick test subset.
Missing a Trained Output File?
Confirm that the PowerShell command finished after the third epoch. Closing the terminal during training prevents the save calls from running.
Check that both save calls appear below model.fit() inside main().
help me find why my trained model or prediction grid was not saved
✔️ Awesome, I've got everything!
Great. The compilation, training, model-save, and trained-grid code are all in place.
ⓧ I'd like to double check the full code
This reference shows the complete cumulative brain_tumor_audit.py file through the trained prediction grid:
import os
import shutil
from pathlib import Path
os.environ["KERAS_BACKEND"] = "tensorflow"
import keras
import matplotlib.pyplot as plt
import tensorflow as tf
SEED = 123
IMAGE_SIZE = (160, 160)
BATCH_SIZE = 16
EPOCHS = 3
TRAIN_PER_CLASS = 150
VALIDATION_PER_CLASS = 50
TEST_PER_CLASS = 50
CLASS_NAMES = ["glioma", "meningioma", "no_tumor", "pituitary"]
DATA_ROOT = Path("data/brisc2025/classification_task")
QUICK_ROOT = Path("quick_data")
OUTPUT_DIR = Path("outputs")
keras.utils.set_random_seed(SEED)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
def make_balanced_subset(source_dir, target_dir, per_class, start_index=0):
if target_dir.exists():
return
target_dir.mkdir(parents=True, exist_ok=True)
for class_name in CLASS_NAMES:
source_class = source_dir / class_name
target_class = target_dir / class_name
if not source_class.exists():
raise FileNotFoundError(
f"Missing class folder: {source_class}. Check the BRISC extraction path."
)
image_paths = sorted(source_class.glob("*.jpg"))
selected = image_paths[start_index : start_index + per_class]
if len(selected) != per_class:
raise ValueError(
f"Expected {per_class} images for {class_name}, found {len(selected)}."
)
target_class.mkdir(parents=True, exist_ok=True)
for image_path in selected:
shutil.copy2(image_path, target_class / image_path.name)
def load_dataset(directory, shuffle):
return keras.utils.image_dataset_from_directory(
directory,
labels="inferred",
label_mode="int",
class_names=CLASS_NAMES,
color_mode="rgb",
batch_size=BATCH_SIZE,
image_size=IMAGE_SIZE,
shuffle=shuffle,
seed=SEED,
)
def build_model():
base_model = keras.applications.MobileNetV2(
input_shape=IMAGE_SIZE + (3,),
include_top=False,
weights="imagenet",
pooling="avg",
)
base_model.trainable = False
inputs = keras.Input(shape=IMAGE_SIZE + (3,))
x = keras.applications.mobilenet_v2.preprocess_input(inputs)
x = base_model(x, training=False)
outputs = keras.layers.Dense(len(CLASS_NAMES))(x)
model = keras.Model(inputs=inputs, outputs=outputs, name="brisc_mri_classifier")
model.compile(
optimizer=keras.optimizers.Adam(learning_rate=0.001),
loss=keras.losses.SparseCategoricalCrossentropy(from_logits=True),
metrics=["accuracy"],
)
return model
def save_prediction_grid(images, labels, logits, output_path, title):
probabilities = tf.nn.softmax(logits, axis=-1)
predictions = tf.argmax(
probabilities, axis=-1, output_type=tf.int32
).numpy()
confidences = tf.reduce_max(probabilities, axis=-1).numpy()
fig, axes = plt.subplots(2, 4, figsize=(12, 6))
count = min(8, int(images.shape[0]))
for index, ax in enumerate(axes.flat):
ax.axis("off")
if index >= count:
continue
true_index = int(labels[index].numpy())
ax.imshow(images[index].numpy().astype("uint8"))
ax.set_title(
f"True: {CLASS_NAMES[true_index]}\n"
f"Pred: {CLASS_NAMES[predictions[index]]} "
f"({confidences[index]:.2f})"
)
fig.suptitle(title)
fig.tight_layout()
fig.savefig(output_path, dpi=160, bbox_inches="tight")
def save_confusion_matrix(matrix, output_path):
values = matrix.numpy()
fig, ax = plt.subplots(figsize=(7, 6))
image = ax.imshow(values, cmap="Blues")
ax.set_xticks(range(len(CLASS_NAMES)), labels=CLASS_NAMES, rotation=30)
ax.set_yticks(range(len(CLASS_NAMES)), labels=CLASS_NAMES)
ax.set_xlabel("Predicted class")
ax.set_ylabel("Actual class")
ax.set_title("BRISC quick-subset confusion matrix")
for row in range(len(CLASS_NAMES)):
for column in range(len(CLASS_NAMES)):
ax.text(column, row, str(values[row, column]), ha="center", va="center")
fig.colorbar(image, ax=ax)
fig.tight_layout()
fig.savefig(output_path, dpi=160, bbox_inches="tight")
def write_metrics(matrix, output_path):
values = matrix.numpy()
correct = sum(int(values[index, index]) for index in range(len(CLASS_NAMES)))
total = sum(
int(values[row, column])
for row in range(len(CLASS_NAMES))
for column in range(len(CLASS_NAMES))
)
lines = [
"Research prototype only. Not validated for diagnosis or clinical use.",
f"Quick-subset accuracy: {correct / total:.3f}",
"",
"Per-class metrics:",
]
for index, class_name in enumerate(CLASS_NAMES):
true_positive = int(values[index, index])
predicted_total = sum(
int(values[row, index]) for row in range(len(CLASS_NAMES))
)
actual_total = sum(
int(values[index, column]) for column in range(len(CLASS_NAMES))
)
precision = true_positive / predicted_total if predicted_total else 0.0
recall = true_positive / actual_total if actual_total else 0.0
lines.append(
f"{class_name}: precision={precision:.3f}, recall={recall:.3f}"
)
output_path.write_text("\n".join(lines), encoding="utf-8")
def integrated_gradients(model, image, target_class, steps=24):
image = tf.cast(image, tf.float32)
baseline = tf.zeros_like(image)
alphas = tf.linspace(0.0, 1.0, steps + 1)
interpolated = baseline + alphas[:, None, None, None] * (image - baseline)
with tf.GradientTape() as tape:
tape.watch(interpolated)
logits = model(interpolated, training=False)
probabilities = tf.nn.softmax(logits, axis=-1)
target_scores = probabilities[:, target_class]
gradients = tape.gradient(target_scores, interpolated)
trapezoids = (gradients[:-1] + gradients[1:]) / 2.0
average_gradients = tf.reduce_mean(trapezoids, axis=0)
attributions = (image[0] - baseline[0]) * average_gradients
heatmap = tf.reduce_sum(tf.abs(attributions), axis=-1)
return heatmap / (tf.reduce_max(heatmap) + 1e-8)
def save_integrated_gradients(model, image, output_path):
logits = model(image, training=False)
target_class = int(
tf.argmax(logits[0], axis=-1, output_type=tf.int32).numpy()
)
heatmap = integrated_gradients(model, image, target_class)
fig, (source_ax, overlay_ax) = plt.subplots(1, 2, figsize=(9, 4))
source_ax.imshow(image[0].numpy().astype("uint8"))
source_ax.set_title("Held-out MRI slice")
source_ax.axis("off")
overlay_ax.imshow(image[0].numpy().astype("uint8"))
overlay = overlay_ax.imshow(heatmap.numpy(), cmap="inferno", alpha=0.5)
overlay_ax.set_title(
f"Influence map for: {CLASS_NAMES[target_class]}\n"
"Not a segmentation mask"
)
overlay_ax.axis("off")
fig.colorbar(overlay, ax=overlay_ax, fraction=0.046, pad=0.04)
fig.tight_layout()
fig.savefig(output_path, dpi=160, bbox_inches="tight")
def main():
train_source = DATA_ROOT / "train"
test_source = DATA_ROOT / "test"
if not train_source.exists() or not test_source.exists():
raise FileNotFoundError(
"BRISC classification folders were not found. Expected "
"data/brisc2025/classification_task/train and test."
)
make_balanced_subset(
train_source,
QUICK_ROOT / "train",
TRAIN_PER_CLASS,
start_index=0,
)
make_balanced_subset(
train_source,
QUICK_ROOT / "validation",
VALIDATION_PER_CLASS,
start_index=TRAIN_PER_CLASS,
)
make_balanced_subset(
test_source,
QUICK_ROOT / "test",
TEST_PER_CLASS,
start_index=0,
)
train_ds = load_dataset(QUICK_ROOT / "train", shuffle=True)
validation_ds = load_dataset(QUICK_ROOT / "validation", shuffle=False)
test_ds = load_dataset(QUICK_ROOT / "test", shuffle=False)
sample_images, sample_labels = next(iter(test_ds))
model = build_model()
untrained_logits = model(sample_images, training=False)
save_prediction_grid(
sample_images,
sample_labels,
untrained_logits,
OUTPUT_DIR / "untrained_predictions.png",
"Before training: arbitrary classifier-head predictions",
)
model.fit(
train_ds,
validation_data=validation_ds,
epochs=EPOCHS,
verbose=2,
)
model.save(OUTPUT_DIR / "brain_tumor_classifier.keras")
trained_logits = model(sample_images, training=False)
save_prediction_grid(
sample_images,
sample_labels,
trained_logits,
OUTPUT_DIR / "trained_predictions.png",
"After training: learned BRISC quick-subset predictions",
)
if __name__ == "__main__":
main()
Where Is This Step's Code?
The compilation block belongs at the end of build_model() before its return statement. The training and save blocks belong in main() after the untrained grid.
Your classifier head now maps pretrained image features to the four BRISC classes. Next, you will audit its held-out errors to see whether any class is being overlooked.
Audit Performance by Class
Your trained BRISC prediction grid now shows learned labels for eight held-out images. That sample cannot reveal whether the model consistently struggles with one class.
Overall accuracy can hide those weak spots. A confusion matrix exposes class-specific mistakes while precision and recall measure each class from different angles.
In this step, get ready to:
- Evaluate the trained classifier using only quick_data/test.
- Create an annotated four-by-four confusion matrix.
- Record accuracy plus per-class precision and recall.
Evaluate the held-out test subset
The images in quick_data/test stayed outside the training process. Evaluating them now measures how the trained head behaves on unseen examples from the published test split.
The audit helpers already exist in brain_tumor_audit.py. This addition connects the trained model to those helpers with TensorFlow predictions.
- In brain_tumor_audit.py, locate the trained save_prediction_grid(...) call inside main().
- Add the held-out audit directly below that call by pasting this code:
test_batches = list(test_ds)
test_images = tf.concat([images for images, _ in test_batches], axis=0)
y_true = tf.concat([labels for _, labels in test_batches], axis=0)
test_logits = model.predict(test_images, batch_size=BATCH_SIZE, verbose=0)
y_pred = tf.argmax(test_logits, axis=-1, output_type=tf.int32)
matrix = tf.math.confusion_matrix(
y_true,
y_pred,
num_classes=len(CLASS_NAMES),
)
save_confusion_matrix(matrix, OUTPUT_DIR / "confusion_matrix.png")
write_metrics(matrix, OUTPUT_DIR / "metrics.txt")
What Does This Code Do?
- The test_batches list collects every held-out batch from test_ds.
- The two tf.concat calls join the test images and their true labels into complete tensors.
- The model.predict call produces four logits for each held-out image.
- The tf.argmax call selects the predicted class with the largest logit.
- The tf.math.confusion_matrix call counts actual classes by row and predicted classes by column.
- The two helper calls save the annotated matrix and the text report in outputs.
- Save brain_tumor_audit.py.
Before you run the audit, which class do you expect the model to confuse most? Hold that prediction in mind.
- Return to the activated PowerShell window from earlier.
- Run the complete audit by running this command:
python brain_tumor_audit.py
What Does This Run Do?
The script rebuilds the balanced subsets only when needed. It retrains the classifier head before evaluating all 200 held-out test images.
The repeat takes about as long as your previous training run. Seeing the three epoch summaries again means the workflow is progressing normally.
When the script finishes, you will see Audit complete. Review the files in the outputs folder. The outputs folder now contains confusion_matrix.png and metrics.txt.
Audit Files Missing?
Check that the new audit block sits inside main() at the same indentation level as the trained prediction code.
Confirm that both save calls appear after the closing parenthesis of tf.math.confusion_matrix.
Use the activated .venv from earlier if the run stops before training begins.
help me debug the missing audit outputs
✔️ Awesome, I've got everything!
Your held-out audit is connected to the trained model. Keep brain_tumor_audit.py saved before reviewing the results.
ⓧ I'd like to double check the full code
Compare your cumulative brain_tumor_audit.py file with this version:
import os
import shutil
from pathlib import Path
os.environ["KERAS_BACKEND"] = "tensorflow"
import keras
import matplotlib.pyplot as plt
import tensorflow as tf
SEED = 123
IMAGE_SIZE = (160, 160)
BATCH_SIZE = 16
EPOCHS = 3
TRAIN_PER_CLASS = 150
VALIDATION_PER_CLASS = 50
TEST_PER_CLASS = 50
CLASS_NAMES = ["glioma", "meningioma", "no_tumor", "pituitary"]
DATA_ROOT = Path("data/brisc2025/classification_task")
QUICK_ROOT = Path("quick_data")
OUTPUT_DIR = Path("outputs")
keras.utils.set_random_seed(SEED)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
def make_balanced_subset(source_dir, target_dir, per_class, start_index=0):
if target_dir.exists():
return
target_dir.mkdir(parents=True, exist_ok=True)
for class_name in CLASS_NAMES:
source_class = source_dir / class_name
target_class = target_dir / class_name
if not source_class.exists():
raise FileNotFoundError(
f"Missing class folder: {source_class}. Check the BRISC extraction path."
)
image_paths = sorted(source_class.glob("*.jpg"))
selected = image_paths[start_index : start_index + per_class]
if len(selected) != per_class:
raise ValueError(
f"Expected {per_class} images for {class_name}, found {len(selected)}."
)
target_class.mkdir(parents=True, exist_ok=True)
for image_path in selected:
shutil.copy2(image_path, target_class / image_path.name)
def load_dataset(directory, shuffle):
return keras.utils.image_dataset_from_directory(
directory,
labels="inferred",
label_mode="int",
class_names=CLASS_NAMES,
color_mode="rgb",
batch_size=BATCH_SIZE,
image_size=IMAGE_SIZE,
shuffle=shuffle,
seed=SEED,
)
def build_model():
base_model = keras.applications.MobileNetV2(
input_shape=IMAGE_SIZE + (3,),
include_top=False,
weights="imagenet",
pooling="avg",
)
base_model.trainable = False
inputs = keras.Input(shape=IMAGE_SIZE + (3,))
x = keras.applications.mobilenet_v2.preprocess_input(inputs)
x = base_model(x, training=False)
outputs = keras.layers.Dense(len(CLASS_NAMES))(x)
model = keras.Model(inputs=inputs, outputs=outputs, name="brisc_mri_classifier")
model.compile(
optimizer=keras.optimizers.Adam(learning_rate=0.001),
loss=keras.losses.SparseCategoricalCrossentropy(from_logits=True),
metrics=["accuracy"],
)
return model
def save_prediction_grid(images, labels, logits, output_path, title):
probabilities = tf.nn.softmax(logits, axis=-1)
predictions = tf.argmax(
probabilities, axis=-1, output_type=tf.int32
).numpy()
confidences = tf.reduce_max(probabilities, axis=-1).numpy()
fig, axes = plt.subplots(2, 4, figsize=(12, 6))
count = min(8, int(images.shape[0]))
for index, ax in enumerate(axes.flat):
ax.axis("off")
if index >= count:
continue
true_index = int(labels[index].numpy())
ax.imshow(images[index].numpy().astype("uint8"))
ax.set_title(
f"True: {CLASS_NAMES[true_index]}\n"
f"Pred: {CLASS_NAMES[predictions[index]]} "
f"({confidences[index]:.2f})"
)
fig.suptitle(title)
fig.tight_layout()
fig.savefig(output_path, dpi=160, bbox_inches="tight")
def save_confusion_matrix(matrix, output_path):
values = matrix.numpy()
fig, ax = plt.subplots(figsize=(7, 6))
image = ax.imshow(values, cmap="Blues")
ax.set_xticks(range(len(CLASS_NAMES)), labels=CLASS_NAMES, rotation=30)
ax.set_yticks(range(len(CLASS_NAMES)), labels=CLASS_NAMES)
ax.set_xlabel("Predicted class")
ax.set_ylabel("Actual class")
ax.set_title("BRISC quick-subset confusion matrix")
for row in range(len(CLASS_NAMES)):
for column in range(len(CLASS_NAMES)):
ax.text(column, row, str(values[row, column]), ha="center", va="center")
fig.colorbar(image, ax=ax)
fig.tight_layout()
fig.savefig(output_path, dpi=160, bbox_inches="tight")
def write_metrics(matrix, output_path):
values = matrix.numpy()
correct = sum(int(values[index, index]) for index in range(len(CLASS_NAMES)))
total = sum(
int(values[row, column])
for row in range(len(CLASS_NAMES))
for column in range(len(CLASS_NAMES))
)
lines = [
"Research prototype only. Not validated for diagnosis or clinical use.",
f"Quick-subset accuracy: {correct / total:.3f}",
"",
"Per-class metrics:",
]
for index, class_name in enumerate(CLASS_NAMES):
true_positive = int(values[index, index])
predicted_total = sum(
int(values[row, index]) for row in range(len(CLASS_NAMES))
)
actual_total = sum(
int(values[index, column]) for column in range(len(CLASS_NAMES))
)
precision = true_positive / predicted_total if predicted_total else 0.0
recall = true_positive / actual_total if actual_total else 0.0
lines.append(
f"{class_name}: precision={precision:.3f}, recall={recall:.3f}"
)
output_path.write_text("\n".join(lines), encoding="utf-8")
def integrated_gradients(model, image, target_class, steps=24):
image = tf.cast(image, tf.float32)
baseline = tf.zeros_like(image)
alphas = tf.linspace(0.0, 1.0, steps + 1)
interpolated = baseline + alphas[:, None, None, None] * (image - baseline)
with tf.GradientTape() as tape:
tape.watch(interpolated)
logits = model(interpolated, training=False)
probabilities = tf.nn.softmax(logits, axis=-1)
target_scores = probabilities[:, target_class]
gradients = tape.gradient(target_scores, interpolated)
trapezoids = (gradients[:-1] + gradients[1:]) / 2.0
average_gradients = tf.reduce_mean(trapezoids, axis=0)
attributions = (image[0] - baseline[0]) * average_gradients
heatmap = tf.reduce_sum(tf.abs(attributions), axis=-1)
return heatmap / (tf.reduce_max(heatmap) + 1e-8)
def save_integrated_gradients(model, image, output_path):
logits = model(image, training=False)
target_class = int(
tf.argmax(logits[0], axis=-1, output_type=tf.int32).numpy()
)
heatmap = integrated_gradients(model, image, target_class)
fig, (source_ax, overlay_ax) = plt.subplots(1, 2, figsize=(9, 4))
source_ax.imshow(image[0].numpy().astype("uint8"))
source_ax.set_title("Held-out MRI slice")
source_ax.axis("off")
overlay_ax.imshow(image[0].numpy().astype("uint8"))
overlay = overlay_ax.imshow(heatmap.numpy(), cmap="inferno", alpha=0.5)
overlay_ax.set_title(
f"Influence map for: {CLASS_NAMES[target_class]}\n"
"Not a segmentation mask"
)
overlay_ax.axis("off")
fig.colorbar(overlay, ax=overlay_ax, fraction=0.046, pad=0.04)
fig.tight_layout()
fig.savefig(output_path, dpi=160, bbox_inches="tight")
def main():
train_source = DATA_ROOT / "train"
test_source = DATA_ROOT / "test"
if not train_source.exists() or not test_source.exists():
raise FileNotFoundError(
"BRISC classification folders were not found. Expected "
"data/brisc2025/classification_task/train and test."
)
make_balanced_subset(
train_source,
QUICK_ROOT / "train",
TRAIN_PER_CLASS,
start_index=0,
)
make_balanced_subset(
train_source,
QUICK_ROOT / "validation",
VALIDATION_PER_CLASS,
start_index=TRAIN_PER_CLASS,
)
make_balanced_subset(
test_source,
QUICK_ROOT / "test",
TEST_PER_CLASS,
start_index=0,
)
train_ds = load_dataset(QUICK_ROOT / "train", shuffle=True)
validation_ds = load_dataset(QUICK_ROOT / "validation", shuffle=False)
test_ds = load_dataset(QUICK_ROOT / "test", shuffle=False)
sample_images, sample_labels = next(iter(test_ds))
model = build_model()
untrained_logits = model(sample_images, training=False)
save_prediction_grid(
sample_images,
sample_labels,
untrained_logits,
OUTPUT_DIR / "untrained_predictions.png",
"Before training: arbitrary classifier-head predictions",
)
model.fit(
train_ds,
validation_data=validation_ds,
epochs=EPOCHS,
verbose=2,
)
model.save(OUTPUT_DIR / "brain_tumor_classifier.keras")
trained_logits = model(sample_images, training=False)
save_prediction_grid(
sample_images,
sample_labels,
trained_logits,
OUTPUT_DIR / "trained_predictions.png",
"After training: learned BRISC quick-subset predictions",
)
test_batches = list(test_ds)
test_images = tf.concat([images for images, _ in test_batches], axis=0)
y_true = tf.concat([labels for _, labels in test_batches], axis=0)
test_logits = model.predict(test_images, batch_size=BATCH_SIZE, verbose=0)
y_pred = tf.argmax(test_logits, axis=-1, output_type=tf.int32)
matrix = tf.math.confusion_matrix(
y_true,
y_pred,
num_classes=len(CLASS_NAMES),
)
save_confusion_matrix(matrix, OUTPUT_DIR / "confusion_matrix.png")
write_metrics(matrix, OUTPUT_DIR / "metrics.txt")
print("Audit complete. Review the files in the outputs folder.")
print("This model is a research prototype and is not for clinical use.")
if __name__ == "__main__":
main()
How Is the File Organized?
The helper functions define the prediction grid and class-level reports. The main() function now sends only test_ds through the trained model for the audit.
The two save calls turn the resulting matrix into a PNG and a text report. Those files provide the evidence you inspect next.
Inspect the class-level evidence
Precision measures how often predictions for a class are correct. Recall measures how many actual images from that class the model recognizes.
The matrix connects both measurements to visible counts. Its diagonal cells represent correct predictions while off-diagonal cells reveal which classes were confused.
- Press the Windows key to open search.
- Type File Explorer into the search field.
- Press Enter.
- Navigate to the brain-tumor-audit\outputs folder.
- Double-click confusion_matrix.png.
You will see a four-by-four grid with counts in every cell. The rows represent actual classes while the columns represent predicted classes.
The labels follow CLASS_NAMES order: glioma, meningioma, no_tumor, and pituitary.
Before you open the report, which class do you expect to have the lowest recall? Keep your answer in mind.
- Return to the outputs folder in File Explorer.
- Double-click metrics.txt.
The report begins with Research prototype only. Not validated for diagnosis or clinical use. It then lists quick-subset accuracy plus a precision and recall value for every class.
Compare the four recall values with your prediction. The lowest value identifies the class whose actual test images were missed most often.
You now have class-specific evidence that exposes behavior hidden by one accuracy number. Next, you will trace one prediction back to the pixels that influenced it.
Explain a Prediction with Integrated Gradients
Your held-out confusion matrix now shows class-specific mistakes. The metrics report captures precision plus recall for every BRISC class.
Those class-aware results cannot show which pixels influenced one prediction. Integrated Gradients adds an attribution view that helps you spot possible reliance on brain tissue, image borders, or other visible regions.
In this step, get ready to:
- Trace how the attribution calculation connects the black baseline to one held-out MRI.
- Connect the existing explanation helpers to the main audit workflow.
- Run the audit to create a two-panel influence-map image.
Trace the attribution calculation
An attribution method assigns an influence value to each input pixel for a chosen prediction. Your helper uses TensorFlow gradients along a path from a black reference image to the real MRI.
- Return to brain_tumor_audit.py from earlier.
- Find def integrated_gradients(model, image, target_class, steps=24):.
- Trace the function from baseline = tf.zeros_like(image) through the normalized return line.
How does Integrated Gradients work?
- The black baseline from tf.zeros_like(image) provides a same-shape reference with zero-valued pixels.
- The tf.linspace path moves from that baseline to the real MRI across steps=24 intervals.
- The tf.GradientTape block measures how each interpolated pixel affects the predicted class probability.
- Trapezoidal averaging combines neighboring gradients along the interpolation path.
- The absolute channel-summed attributions become a heatmap normalized between zero and one.
What can this attribution show?
Bright regions identify pixels that influenced this specific prediction along the selected baseline path. They can reveal attention on brain tissue, image borders, or other visible artifacts.
The heatmap does not locate a tumor. It does not establish a diagnosis or prove clinical validity.
Generate the influence map
The existing save_integrated_gradients() helper turns the normalized heatmap into a two-panel image. The remaining connection passes one held-out MRI to that helper after the class audit finishes.
- In brain_tumor_audit.py, find write_metrics(matrix, OUTPUT_DIR / "metrics.txt") inside main().
- Connect the held-out image to the overlay helper by adding this call directly below that line:
save_integrated_gradients(
model,
test_images[0:1],
OUTPUT_DIR / "integrated_gradients.png",
)
What does this code do?
- The slice test_images[0:1] selects the first held-out MRI while preserving the batch dimension expected by the model.
- The helper predicts that image's class from the four logits.
- The helper calculates the attribution heatmap for the predicted CLASS_NAMES class.
- The output path saves the finished figure as outputs/integrated_gradients.png.
- Save brain_tumor_audit.py.
✔️ Awesome, I've got everything!
Your saved script now connects the held-out test image to the Integrated Gradients output.
ⓧ I'd like to double check the full code
Compare your saved brain_tumor_audit.py with the complete audit script below:
import os
import shutil
from pathlib import Path
os.environ["KERAS_BACKEND"] = "tensorflow"
import keras
import matplotlib.pyplot as plt
import tensorflow as tf
SEED = 123
IMAGE_SIZE = (160, 160)
BATCH_SIZE = 16
EPOCHS = 3
TRAIN_PER_CLASS = 150
VALIDATION_PER_CLASS = 50
TEST_PER_CLASS = 50
CLASS_NAMES = ["glioma", "meningioma", "no_tumor", "pituitary"]
DATA_ROOT = Path("data/brisc2025/classification_task")
QUICK_ROOT = Path("quick_data")
OUTPUT_DIR = Path("outputs")
keras.utils.set_random_seed(SEED)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
def make_balanced_subset(source_dir, target_dir, per_class, start_index=0):
if target_dir.exists():
return
target_dir.mkdir(parents=True, exist_ok=True)
for class_name in CLASS_NAMES:
source_class = source_dir / class_name
target_class = target_dir / class_name
if not source_class.exists():
raise FileNotFoundError(
f"Missing class folder: {source_class}. Check the BRISC extraction path."
)
image_paths = sorted(source_class.glob("*.jpg"))
selected = image_paths[start_index : start_index + per_class]
if len(selected) != per_class:
raise ValueError(
f"Expected {per_class} images for {class_name}, found {len(selected)}."
)
target_class.mkdir(parents=True, exist_ok=True)
for image_path in selected:
shutil.copy2(image_path, target_class / image_path.name)
def load_dataset(directory, shuffle):
return keras.utils.image_dataset_from_directory(
directory,
labels="inferred",
label_mode="int",
class_names=CLASS_NAMES,
color_mode="rgb",
batch_size=BATCH_SIZE,
image_size=IMAGE_SIZE,
shuffle=shuffle,
seed=SEED,
)
def build_model():
base_model = keras.applications.MobileNetV2(
input_shape=IMAGE_SIZE + (3,),
include_top=False,
weights="imagenet",
pooling="avg",
)
base_model.trainable = False
inputs = keras.Input(shape=IMAGE_SIZE + (3,))
x = keras.applications.mobilenet_v2.preprocess_input(inputs)
x = base_model(x, training=False)
outputs = keras.layers.Dense(len(CLASS_NAMES))(x)
model = keras.Model(inputs=inputs, outputs=outputs, name="brisc_mri_classifier")
model.compile(
optimizer=keras.optimizers.Adam(learning_rate=0.001),
loss=keras.losses.SparseCategoricalCrossentropy(from_logits=True),
metrics=["accuracy"],
)
return model
def save_prediction_grid(images, labels, logits, output_path, title):
probabilities = tf.nn.softmax(logits, axis=-1)
predictions = tf.argmax(
probabilities, axis=-1, output_type=tf.int32
).numpy()
confidences = tf.reduce_max(probabilities, axis=-1).numpy()
fig, axes = plt.subplots(2, 4, figsize=(12, 6))
count = min(8, int(images.shape[0]))
for index, ax in enumerate(axes.flat):
ax.axis("off")
if index >= count:
continue
true_index = int(labels[index].numpy())
ax.imshow(images[index].numpy().astype("uint8"))
ax.set_title(
f"True: {CLASS_NAMES[true_index]}\n"
f"Pred: {CLASS_NAMES[predictions[index]]} "
f"({confidences[index]:.2f})"
)
fig.suptitle(title)
fig.tight_layout()
fig.savefig(output_path, dpi=160, bbox_inches="tight")
def save_confusion_matrix(matrix, output_path):
values = matrix.numpy()
fig, ax = plt.subplots(figsize=(7, 6))
image = ax.imshow(values, cmap="Blues")
ax.set_xticks(range(len(CLASS_NAMES)), labels=CLASS_NAMES, rotation=30)
ax.set_yticks(range(len(CLASS_NAMES)), labels=CLASS_NAMES)
ax.set_xlabel("Predicted class")
ax.set_ylabel("Actual class")
ax.set_title("BRISC quick-subset confusion matrix")
for row in range(len(CLASS_NAMES)):
for column in range(len(CLASS_NAMES)):
ax.text(column, row, str(values[row, column]), ha="center", va="center")
fig.colorbar(image, ax=ax)
fig.tight_layout()
fig.savefig(output_path, dpi=160, bbox_inches="tight")
def write_metrics(matrix, output_path):
values = matrix.numpy()
correct = sum(int(values[index, index]) for index in range(len(CLASS_NAMES)))
total = sum(
int(values[row, column])
for row in range(len(CLASS_NAMES))
for column in range(len(CLASS_NAMES))
)
lines = [
"Research prototype only. Not validated for diagnosis or clinical use.",
f"Quick-subset accuracy: {correct / total:.3f}",
"",
"Per-class metrics:",
]
for index, class_name in enumerate(CLASS_NAMES):
true_positive = int(values[index, index])
predicted_total = sum(
int(values[row, index]) for row in range(len(CLASS_NAMES))
)
actual_total = sum(
int(values[index, column]) for column in range(len(CLASS_NAMES))
)
precision = true_positive / predicted_total if predicted_total else 0.0
recall = true_positive / actual_total if actual_total else 0.0
lines.append(
f"{class_name}: precision={precision:.3f}, recall={recall:.3f}"
)
output_path.write_text("\n".join(lines), encoding="utf-8")
def integrated_gradients(model, image, target_class, steps=24):
image = tf.cast(image, tf.float32)
baseline = tf.zeros_like(image)
alphas = tf.linspace(0.0, 1.0, steps + 1)
interpolated = baseline + alphas[:, None, None, None] * (image - baseline)
with tf.GradientTape() as tape:
tape.watch(interpolated)
logits = model(interpolated, training=False)
probabilities = tf.nn.softmax(logits, axis=-1)
target_scores = probabilities[:, target_class]
gradients = tape.gradient(target_scores, interpolated)
trapezoids = (gradients[:-1] + gradients[1:]) / 2.0
average_gradients = tf.reduce_mean(trapezoids, axis=0)
attributions = (image[0] - baseline[0]) * average_gradients
heatmap = tf.reduce_sum(tf.abs(attributions), axis=-1)
return heatmap / (tf.reduce_max(heatmap) + 1e-8)
def save_integrated_gradients(model, image, output_path):
logits = model(image, training=False)
target_class = int(
tf.argmax(logits[0], axis=-1, output_type=tf.int32).numpy()
)
heatmap = integrated_gradients(model, image, target_class)
fig, (source_ax, overlay_ax) = plt.subplots(1, 2, figsize=(9, 4))
source_ax.imshow(image[0].numpy().astype("uint8"))
source_ax.set_title("Held-out MRI slice")
source_ax.axis("off")
overlay_ax.imshow(image[0].numpy().astype("uint8"))
overlay = overlay_ax.imshow(heatmap.numpy(), cmap="inferno", alpha=0.5)
overlay_ax.set_title(
f"Influence map for: {CLASS_NAMES[target_class]}\n"
"Not a segmentation mask"
)
overlay_ax.axis("off")
fig.colorbar(overlay, ax=overlay_ax, fraction=0.046, pad=0.04)
fig.tight_layout()
fig.savefig(output_path, dpi=160, bbox_inches="tight")
def main():
train_source = DATA_ROOT / "train"
test_source = DATA_ROOT / "test"
if not train_source.exists() or not test_source.exists():
raise FileNotFoundError(
"BRISC classification folders were not found. Expected "
"data/brisc2025/classification_task/train and test."
)
make_balanced_subset(
train_source,
QUICK_ROOT / "train",
TRAIN_PER_CLASS,
start_index=0,
)
make_balanced_subset(
train_source,
QUICK_ROOT / "validation",
VALIDATION_PER_CLASS,
start_index=TRAIN_PER_CLASS,
)
make_balanced_subset(
test_source,
QUICK_ROOT / "test",
TEST_PER_CLASS,
start_index=0,
)
train_ds = load_dataset(QUICK_ROOT / "train", shuffle=True)
validation_ds = load_dataset(QUICK_ROOT / "validation", shuffle=False)
test_ds = load_dataset(QUICK_ROOT / "test", shuffle=False)
sample_images, sample_labels = next(iter(test_ds))
model = build_model()
untrained_logits = model(sample_images, training=False)
save_prediction_grid(
sample_images,
sample_labels,
untrained_logits,
OUTPUT_DIR / "untrained_predictions.png",
"Before training: arbitrary classifier-head predictions",
)
model.fit(
train_ds,
validation_data=validation_ds,
epochs=EPOCHS,
verbose=2,
)
model.save(OUTPUT_DIR / "brain_tumor_classifier.keras")
trained_logits = model(sample_images, training=False)
save_prediction_grid(
sample_images,
sample_labels,
trained_logits,
OUTPUT_DIR / "trained_predictions.png",
"After training: learned BRISC quick-subset predictions",
)
test_batches = list(test_ds)
test_images = tf.concat([images for images, _ in test_batches], axis=0)
y_true = tf.concat([labels for _, labels in test_batches], axis=0)
test_logits = model.predict(test_images, batch_size=BATCH_SIZE, verbose=0)
y_pred = tf.argmax(test_logits, axis=-1, output_type=tf.int32)
matrix = tf.math.confusion_matrix(
y_true,
y_pred,
num_classes=len(CLASS_NAMES),
)
save_confusion_matrix(matrix, OUTPUT_DIR / "confusion_matrix.png")
write_metrics(matrix, OUTPUT_DIR / "metrics.txt")
save_integrated_gradients(
model,
test_images[0:1],
OUTPUT_DIR / "integrated_gradients.png",
)
print("Audit complete. Review the files in the outputs folder.")
print("This model is a research prototype and is not for clinical use.")
if __name__ == "__main__":
main()
Before you run the full audit, where do you expect the strongest influence colors to appear on the held-out MRI?
- Run the completed audit from the activated .venv by running this command:
python brain_tumor_audit.py
The run retrains the classifier head for three epochs on your CPU. Expect a pause while the epoch summaries print.
You will see three epoch summaries. The final messages confirm that the audit completed and remind you that the model is a research prototype.
- Press the Windows key to open the search bar.
- Type File Explorer into the search bar.
- Press Enter to open File Explorer.
- Navigate to the outputs folder inside brain-tumor-audit.
- Double-click integrated_gradients.png.
You should see one held-out MRI on the left. The right panel shows an inferno-colored overlay titled with the predicted class plus Not a segmentation mask.
How should you read the overlay?
Brighter regions had greater influence on the selected class probability along the black-baseline path. Compare those regions with the source MRI to identify visible patterns worth investigating.
This image is an influence visualization for one prediction. Its evidence supports model debugging within this research prototype.
Missing the influence map?
Check that the save_integrated_gradients() call sits inside main() with the same indentation as write_metrics().
Confirm that the output filename is exactly integrated_gradients.png inside OUTPUT_DIR.
If the audit stops before saving the image, review the final terminal lines for the function where execution stopped.
Help me debug why my Integrated Gradients image was not created.
You have completed the full local audit pipeline. Your classifier now has visible predictions, class-specific error evidence, and a bounded explanation for one held-out result.
Secret mission
Write a Model Card from the Evidence
Turn your audit evidence into a concise model card. Use it to choose one testable follow-on experiment.
Clean Up Your Resources
Clean Up Your Resources
Everything in this project stays on your Windows computer. There are no cloud resources or recurring charges, so your cleanup choice only affects local files.
Resources you used:
- The data/brisc2025 folder containing the local BRISC 2025 training and test data.
- The .venv folder containing your isolated Python packages.
- The quick_data folder containing the balanced training, validation, and test subsets.
- The outputs folder containing the trained model, prediction grids, confusion matrix, metrics report, and influence map.
- The project files brain_tumor_audit.py, requirements.txt, and MODEL_CARD.md.
Keep everything running
No action needed. Choose this if you want to inspect the evidence or continue experimenting with the classifier.
- The brain-tumor-audit folder remains available for later experiments.
- The extracted dataset remains available at data/brisc2025.
- The trained model remains available at outputs/brain_tumor_classifier.keras.
- The audit evidence remains available in outputs.
- No process needs to stay running between sessions.
Pause - I'll come back to this later
Pausing closes local processes to free memory while keeping every project file. Your evidence remains ready for the next experiment.
- Close any Jupyter session using the project.
- Close the PowerShell window containing the activated .venv.
- Keep the brain-tumor-audit folder in its current location.
- Reactivate .venv by repeating the activation step from Step 1 when you return.
Delete - I don't want to use this again
Deleting the project folder looks drastic because it removes every local result in one move. Your Python installation outside brain-tumor-audit stays untouched.
- Close any Jupyter session using the project.
- Close the PowerShell window containing the activated .venv.
- Press the Windows key to open search.
- Enter File Explorer in the search field.
- Press Enter to open File Explorer.
- Browse to the location where you created brain-tumor-audit in Step 1.
- Select the brain-tumor-audit folder.
- Press the Delete key to remove the selected folder.
- Confirm that brain-tumor-audit no longer appears in File Explorer.
This folder deletion removes data/brisc2025, .venv, quick_data, outputs, brain_tumor_audit.py, requirements.txt, and MODEL_CARD.md together.
Nice Work!
Nice Work!
You made it! Your local audit pipeline now uses transfer learning to produce a four-class MRI classifier with evidence you can inspect responsibly.
You've learned how to:
- Train a four-class classifier with a frozen MobileNetV2 feature base. You preserved separate training, validation, and test subsets for a reproducible experiment.
- Compare predictions before and after training on a held-out batch. You audited class-specific behavior with a confusion matrix. You also calculated precision and recall for every BRISC class.
- Create an Integrated Gradients influence map for one prediction. You distinguished attribution from segmentation. You also framed the classifier as a non-clinical research prototype.
- Secret Mission: Produced a model card that documents the lowest-recall class plus one visible attribution observation. You proposed a testable next experiment grounded in that evidence.
Ready to quiz yourself?