DEV Community

kito2718
kito2718

Posted on

BCT3: Building a Fast Local Cross-Validation Environment for 80GB Dataset

Biohub Cell Tracking Local CV Setup
Figure: Conceptual illustration of 3D cell tracking data analysis and high-performance local CV setup

Abstract

In Kaggle's "Biohub - Cell Tracking During Development" competition, performing model iterations on Kaggle Notebooks for an 81.4GB dataset incurs massive wait times for every "Run All", "Save Version", and "Submit". This article demonstrates how to build a fast and reliable local cross-validation (CV) pipeline on a Windows CPU environment using Astral's uv package manager, resolving Windows DLL security blocks, applying PyTorch CPU patches, and optimizing graph node accessors.

Overview

When competing in computer vision and bioinformatics challenges with massive 3D image datasets (.zarr) and temporal cell tracking graphs (.geff), relying solely on cloud platform execution drastically slows down experimentation.

To achieve a fast feedback loop (seconds to minutes per iteration), we constructed a self-contained local evaluation environment that processes all 199 embryo datasets and outputs a micro-averaged CV score perfectly aligned with Kaggle's official evaluation logic.

System Pipeline Flowchart
Figure: Complete workflow of the local CV pipeline setup

Details

(1) Isolated Directory Structure

We organized the virtual environment, dataset, evaluation repository, and notebooks under a clean local_env folder to maintain portable environment isolation.

007_kaggle_Biohub-Cell_Tracking_During_Development/
├── local_env/                                  <- Virtual environment created with uv
│   ├── Scripts/python.exe                      <- Python interpreter path for VS Code
│   ├── data/                                   <- Downloaded Kaggle dataset (81.4 GB)
│   │    ├── train/
│   │    └── test/
│   ├── src/
│   │    ├── eda_visualizer.py
│   │    └── kaggle_cell_tracking_competition/  <- Organizer's official evaluation code
│   │        └── src/tracking_cellmot/
│   └── notebooks/
│        └── local_validation.ipynb             <- Local validation notebook
└── kaggle_Biohub-Cell_Tracking_During_Development/
    └── notebooks/
        └── baseline_pipeline.ipynb             <- Kaggle submission notebook
Enter fullscreen mode Exit fullscreen mode

(2) Fast Package Management with uv

We utilized Astral's Rust-based package manager uv to manage dependencies efficiently.

:: Create local_env virtual environment
uv venv local_env

:: Install dependencies including pre-release tracksdata and CPU PyTorch
uv pip install --prerelease allow --python local_env --extra-index-url https://download.pytorch.org/whl/cpu tracksdata polars zarr scikit-image scipy torch pandas matplotlib
Enter fullscreen mode Exit fullscreen mode

(3) Resolving Windows Security DLL Blocks (Smart App Control)

On Windows environments, binaries installed via pip or uv (such as rustworkx .pyd files) may carry a Mark-of-the-Web (MotW) flag, resulting in DLL load failed errors during import. We unblocked all binaries in PowerShell:

Get-ChildItem -Path local_env -Recurse | Unblock-File
Enter fullscreen mode Exit fullscreen mode

(4) CPU Environment Compatibility Patch (io.py)

The organizer's data loader (tracking_cellmot/io.py) throws a RuntimeError on CPU-only environments when invoking .pin_memory(). We patched the loader to conditionally execute .pin_memory() only when CUDA is active.

     if device is not None:
         torch_device = torch.device(device)
         tensor = torch.from_numpy(image).to(torch_device)
-        if pin_memory:
-            tensor = tensor.pin_memory()
+        # Execute pin_memory only when CUDA GPU is available
+        if pin_memory and torch_device.type == "cuda" and torch.cuda.is_available():
+            tensor = tensor.pin_memory()
Enter fullscreen mode Exit fullscreen mode

(5) Resolving Graph Counting Performance Bottleneck

When accessing node and edge counts in tracksdata's InMemoryGraph, calling len(list(predicted_graph.nodes)) causes severe Python overhead due to converting tens of thousands of internal C++/Rust objects into Python lists. We resolved this bottleneck by calling the native accessor methods node_ids() and edge_ids().

# Before (Freezes due to converting 100k+ objects to Python list):
# num_nodes = len(list(predicted_graph.nodes))

# After (Instant O(1) attribute access):
num_nodes = len(predicted_graph.node_ids())
num_edges = len(predicted_graph.edge_ids())
Enter fullscreen mode Exit fullscreen mode

(6) Local Validation Notebook Implementation & GitHub Reference

You can download the ready-to-run notebook directly from our GitHub repository:

Alternatively, create local_env/notebooks/local_validation.ipynb and insert the full source code below:

import os
import sys
import glob
import zarr
import numpy as np
import pandas as pd
from skimage.feature import blob_dog
from scipy.spatial.distance import cdist
from tqdm import tqdm
import polars as pl

# 1. Add organizer repo source to system path
repo_path = os.path.abspath(os.path.join(os.getcwd(), "../src/kaggle_cell_tracking_competition/src"))
if repo_path not in sys.path:
    sys.path.append(repo_path)

import tracksdata as td
from tracking_cellmot.io import open_dataset
from tracking_cellmot.metrics import evaluate, evaluate_datasets

# 2. Data path setup
DATA_DIR = os.path.abspath(os.path.join(os.getcwd(), "../data/train"))
zarr_paths = glob.glob(os.path.join(DATA_DIR, "*.zarr"))
dataset_names = sorted([os.path.basename(p).replace(".zarr", "") for p in zarr_paths])

# Fast iteration setup: Set to 2 for quick testing, None for full CV
NUM_DATASETS_TO_EVAL = 2
eval_datasets = dataset_names if NUM_DATASETS_TO_EVAL is None else dataset_names[:NUM_DATASETS_TO_EVAL]

# 3. Sanity Check (Evaluate Ground Truth directly to verify max score 1.1000)
ds_sanity = open_dataset(os.path.join(DATA_DIR, dataset_names[0]), normalize=True, require_tracks=True, device="cpu")
sanity_res = evaluate(graph=ds_sanity.tracks, gt_graph=ds_sanity.tracks, scale=ds_sanity.scale)
edge_d = sanity_res.edge_tp + sanity_res.edge_fp + sanity_res.edge_fn
edge_j = sanity_res.edge_tp / edge_d if edge_d > 0 else 1.0
div_d = sanity_res.division_tp + sanity_res.division_fp + sanity_res.division_fn
div_j = sanity_res.division_tp / div_d if div_d > 0 else 1.0
print(f"SANITY SCORE: {edge_j + 0.1 * div_j:.4f} (Expected: 1.1000)")

# 4. Main prediction loop
graph_pairs = []
for dataset_name in eval_datasets:
    ds = open_dataset(os.path.join(DATA_DIR, dataset_name), normalize=True, require_tracks=True, device="cpu")
    predicted_graph = td.graph.InMemoryGraph()
    for key in ("z", "y", "x"):
        predicted_graph.add_node_attr_key(key, pl.Float64, 0.0)

    num_frames = ds.image.shape[0]
    prev_nodes_info = []
    for t in tqdm(range(num_frames), desc=f"Frames ({dataset_name})"):
        img_3d = ds.image[t]
        coords = detect_cells_3d(img_3d, min_sigma=2, max_sigma=5, threshold=0.05)
        curr_nodes_info = []
        for idx, (z, y, x) in enumerate(coords):
            node_id = predicted_graph.add_node({"t": int(t), "z": float(z), "y": float(y), "x": float(x)})
            curr_nodes_info.append((idx, node_id, (z, y, x)))

        if len(prev_nodes_info) > 0 and len(curr_nodes_info) > 0:
            coords_prev = np.array([item[2] for item in prev_nodes_info]) * ds.scale
            coords_curr = np.array([item[2] for item in curr_nodes_info]) * ds.scale
            links = track_frame_to_frame(coords_prev, coords_curr, max_distance=15.0)
            for idx_prev, idx_curr in links:
                predicted_graph.add_edge(prev_nodes_info[idx_prev][1], curr_nodes_info[idx_curr][1], {})
        prev_nodes_info = curr_nodes_info

    num_nodes = len(predicted_graph.node_ids())
    num_edges = len(predicted_graph.edge_ids())
    print(f"Dataset {dataset_name} finished: {num_nodes} nodes, {num_edges} edges.")
    graph_pairs.append((predicted_graph, ds.tracks))

# 5. Compute overall CV score
cv_result = evaluate_datasets(graph_pairs=graph_pairs, scale=ds.scale)
print(f"FINAL CV SCORE: {cv_result.score:.6f}")
Enter fullscreen mode Exit fullscreen mode

(7) VS Code Execution Steps

  1. Open the project root folder in VS Code.
  2. Open local_env/notebooks/local_validation.ipynb.
  3. Select local_env/Scripts/python.exe as the kernel interpreter.
  4. Click "Run All". Sanity check (1.1000) completes instantly, followed by the local CV evaluation.

(8) Sanity Check & Baseline Local CV Score Report

We conducted a Sanity Check by passing the ground-truth tracks directly into the evaluator, confirming a theoretical maximum score of 1.1000 (Edge Jaccard: 1.0 + 0.1 * Division Jaccard: 1.0).

Next, we evaluated our 3D DoG detection + nearest neighbor tracking baseline across all 199 embryo datasets, establishing our baseline local CV score:

========================================
===      LOCAL CV SCORE REPORT       ===
========================================
Evaluated datasets count: 199
Edge Jaccard:            0.505233
Division Jaccard:        0.000000
----------------------------------------
FINAL CV SCORE:          0.505233
  (Formula: Edge Jaccard + 0.1 * Division Jaccard)
========================================
Enter fullscreen mode Exit fullscreen mode

The breakdown clearly demonstrates that our baseline achieves 50.5% Edge Jaccard for 1-to-1 cell movements, but scores 0.0 for Division Jaccard because cell division (1-to-2 branching) logic has not yet been implemented.

Conclusion

Establishing a fast local cross-validation environment for an 80GB dataset eliminates cloud platform queueing delays and enables rapid hypothesis testing in seconds to minutes. Next, we will focus on implementing cell division matching logic and global linear assignment matching (Hungarian algorithm) to boost our local CV score.

Hope this helps!

Japanese Series Articles (Zenn)

For Japanese readers, the complete series is published on Zenn:

Top comments (0)