Skip to main content
AboutWorkSkillsExperienceWritingContact
PreviousEMPASNextCriteo Uplift
HomeProjectsAboutExperienceWritingContactGitHubLinkedIn
© 2026 Bhargav Kr Nath
All projects
Analytics/2026/Real, deployed project

Fitness Tracker Analysis.

A 358,497-row PySpark batch pipeline and three scikit-learn models behind a Streamlit dashboard, audited by actually running the shipped pipeline: the dashboard's headline accuracy numbers traced to a model that was never deployed, and one live prediction tool returned the same answer no matter what a user selected. Both are now fixed, live-computed, and covered by regression tests.

View live dashboardView source on GitHub
Solo Data / ML Engineer
PySparkPyArrowscikit-learnStreamlitDockerpytest
audit.log

$ pytest -q

22 passed in 8.82s

$ python -m src.train_dashboard_models

wrote dashboard/models/metrics.json

$ dashboard/1_Overview.py reads it live:

89.3% accuracy, R2 = 0.918

Not a screenshot. Fixed directly in the source project: the training script now scores itself and the dashboard reads the result live.

358,497

Daily records processed

1,959 users, 183 days, Apr to Sep 2023

89.3%

Held-out accuracy, now live

Computed by the training script itself and shown on the dashboard, not a carried-over notebook number

2

Bugs found and fixed

A misattributed accuracy claim and a dead dropdown, both closed with regression tests

Fixed

Live Inference dropdown bug

Used to return one identical prediction for all 6 options, now differentiates correctly

every number above is reproduced in the sections below, with the code or command that produced it
22/22

Tests passing

6 new regression tests added for these fixes

88%

Extraction speedup

0.441s -> 0.055s, full 358K rows

5

Refactor phases

Broken tests to tested package, one day

7.13s

ETL smoke test

124MB peak RSS

The problem

A prototype that looked further along than it was.

A raw fitness export arriving as 183 files nested three directories deep is not yet analyzable. Getting from that to a chart or a prediction means discovering every valid parquet file regardless of nesting depth, computing derived features once rather than once per consumer, and training models whose preprocessing exactly matches what inference applies, all while working within a real constraint: the trained regressor is 326MB and cannot be committed to a public GitHub repo without Git LFS.

The project's own retrospective document, project_refactor.md, is unusually candid about the second problem: by the time of this audit, the repo had accumulated three overlapping training scripts, hardcoded Windows paths, and a test suite that could not even import. Below is what that starting state actually looked like, verified directly rather than taken on its word.

183

Raw source files

Nested parquet, sibling CSVs ignored

7

Activity types

walking, running, cycling, swimming, yoga, gym_workout, hiking

Starting state

What the repo actually looked like.

Not a generic list of tech debt. Six specific, verifiable defects, straight from the project's own retrospective document.

project_refactor.md --starting-state6 findings

Tests did not even import

pytest -q failed with ModuleNotFoundError: No module named 'src' before a single test could run, per the project's own project_refactor.md.

Three scripts trained the same models, differently

train_dashboard_models.py, train_cloud_models.py and train_segmentation_only.py each built a different Random Forest configuration against the same processed data.

A hardcoded Windows path in a live data generator

generate_streaming_data.py wrote to C:/Project/Fitness Tracker Analysis/data_lake/streaming_input, unrunnable outside one specific machine.

A Docker-only path assumed by application code

etl_pipeline.py assumed /app/..., baking a container-only path into code meant to also run locally.

Documentation ahead of the implementation

The README and system_design.svg describe real-time telemetry, a user feedback loop and CI/CD automation. None of these exist in the code.

No package initialization, ad hoc path bootstrapping everywhere

Every entrypoint needed its own sys.path insertion just to import from src, rather than one shared, tested resolution.

The rebuild

Five phases, one day, one commit each.

Verified directly in git history: cleanup, then bug fixes, then pipeline repair, then modularization, then performance, in that order, each phase a single dated commit.

Phase 1: cleanup

3 -> 1 training scripts

Two duplicate training scripts (train_cloud_models.py, train_segmentation_only.py) moved to archive/legacy/, leaving one canonical training path.

Phase 2: bug fixes & paths

0 -> 16 tests passing

Fixed the failing test import (ModuleNotFoundError: No module named 'src') and centralized every path in src/config.py, overridable via FITNESS_TRACKER_ROOT.

Phase 3: pipeline repair

reconnected end to end

Reconnected extract, transform, load, train and serve into one traceable path a developer can actually run end to end.

Phase 4: modularization

1 -> 5 focused modules

Split monolithic scripts into src/etl/{extract,transform,load,run}.py and src/models/training.py, each independently importable and testable.

Phase 5: performance

88% faster extraction

Replaced a per-file pd.read_parquet + pd.concat loop with a single PyArrow dataset scan across all 183 files.

Left unresolved

2 live entrypoints

Two ETL entrypoints still coexist: the Dockerfile runs the pure-PySpark path, the README's Quickstart runs the PyArrow+PySpark path. The refactor's own plan called for choosing one.

Architecture

Five stages, a dashboard that never blocks.

Extract, transform, load, train, serve. The serving layer is designed so a missing model file degrades gracefully rather than failing.

$ python -m src.run_pipeline

1

Extract

One PyArrow dataset scan across all 183 nested parquet files, CSV siblings ignored.

PyArrow
2

Transform

PySpark: day_of_week, calories_to_steps_ratio, a zero-guard for the divide-by-steps case.

PySpark
3

Load

Parquet, partitioned by year and month, overwrite semantics for a simple, reasonable rerun.

Parquet
4

Train

3 scikit-learn Pipelines, fit on 100% of the processed data, no held-out split in the script.

scikit-learn
5

Serve

Streamlit reads the processed parquet and, if present, the pickled pipelines.

Streamlit
If a model file is present

The pickled sklearn Pipeline is loaded and predicts directly, imputation and scaling already baked in.

If it is not

A deterministic heuristic (threshold rule, closed-form estimate, or step-ranked band) answers instantly. Nothing blocks, nothing errors.

Two entrypoints still coexist

src/etl_pipeline.py -> src/etl/run.py

What the Dockerfile runs. Pure PySpark end to end: spark.read.parquet straight through to spark.write.parquet.

src/run_pipeline.py

What the README tells a developer to run. PyArrow extraction, PySpark transform, pandas/PyArrow write, then trains the dashboard models in the same process.

The refactor's own plan called for choosing one canonical path. It was not fully closed out, flagged here rather than described as resolved.

Data

Overlapping activities, a generator with no memory.

358,497 synthetic daily records, 1,959 users, six months. Verified directly: pd.read_parquet on the processed dataset in this environment.

Daily step range by activity, real values from dataset_summary.jsonmin to max, per activity
yoga50 to 499 steps
swimming100 to 999 steps
cycling500 to 4,999 steps
gym_workout1,000 to 5,999 steps
walking3,000 to 14,999 steps
running5,000 to 19,998 steps
hiking7,000 to 24,999 steps
05,00010,00015,00020,00025,000

Adjacent activities overlap by thousands of steps, cycling and gym_workout, walking and running, running and hiking. A plain step-count threshold cannot cleanly separate them; a Random Forest, evaluating steps, calories and heart rate together, earns its keep here in a way the dashboard's own baseline heuristic cannot.

walking / running / cycling / hiking

calories = steps x factor + random noise

steps and calories correlated by construction

swimming / yoga / gym_workout

calories = random range, independent of steps

no relationship to steps at all

Methodology

Preprocessing rides inside the model, not beside it.

Every scikit-learn Pipeline bundles imputation, scaling and encoding with the estimator, fit and pickled as one object.

dashboard/utils.py calls pipeline.predict(raw_features) directly. There is no second, hand-maintained feature-transformation function on the serving side that could silently drift from what training used, a bug class closed by construction rather than by discipline.

The same file caps K-Means at min(5, len(user_df)) rather than a hardcoded 5, specifically so a 2-user test fixture does not crash a request for 5 clusters. It is a small line, and it is exactly the kind of detail that separates code written to pass a demo from code written to survive its own test suite.

src/models/training.py :: train_dashboard_models
1# Every pipeline bundles its own preprocessing,
2# trained and pickled together with the estimator.
3class_pipeline = Pipeline([
4 ("imputer", SimpleImputer(strategy="median")),
5 ("scaler", StandardScaler()),
6 ("classifier", RandomForestClassifier(
7 n_estimators=MODEL_TREES, max_depth=20,
8 n_jobs=-1, random_state=42,
9 )),
10])
11 
12# Capped by data size, not hardcoded: a 2-user
13# test fixture cannot crash this call.
14n_clusters=min(5, len(user_df))
Deployment constraint
The trained artifacts are 69MB and 326MB, the regressor alone is still too large for a plain GitHub commit, and both are too slow to download on a Streamlit Community Cloud cold start. The dashboard is built to be fully interactive with zero model files present: threshold rules, closed-form estimates and step-ranked bands stand in for the trained models until one is available, verified by dedicated tests.
Results

The dashboard cited a model it never shipped.

"84% accuracy" and "R-squared = 0.91" on the Architecture tab matched, to 4 significant figures, a Spark MLlib model from an exploratory notebook, not the Random Forest that dashboard/models/ actually contains. Fixed below: the card now reads its numbers from a metrics.json the training script writes itself.

89.3%

Held-out accuracy, measured

vs. 84% previously claimed on the dashboard, for a different model

0.918

Held-out R-squared, measured

vs. 0.91 previously claimed. RMSE 126.15

0

Evaluations, before this fix

Now: every training run writes dashboard/models/metrics.json

7.5%

Per-user step variation

Coefficient of variation across 1,959 users

Before and after: what the dashboard claimed vs. what actually ships2 metrics, 3 sources each

Activity classifier accuracy

84%
84.03%
89.3%
before fixwrong modelafter fix, live now

Calorie regressor R-squared

0.91
0.9116
0.918
before fixwrong modelafter fix, live now
Claimed on the dashboard, before the fixdashboard/1_Overview.py, "Architecture" tab, a hardcoded string
Real, but the wrong modelSpark MLlib LogisticRegression / LinearRegression, notebooks/1_ETL_and_EDA.ipynb, genuine 80/20 split
The model actually shipped, now shown liveRandomForest, dashboard/models/*.pkl, held-out 80/20 split, computed by train_dashboard_models.py and read from metrics.json

The first two bars in each pair were, to 4 significant figures, the same number, the dashboard's claim was never re-measured after the model behind it changed. The shipped Random Forests turn out to beat that borrowed number, not lag it, which made the missing evaluation more notable, not less. The third bar is no longer a one-off measurement for this case study, it's what dashboard/1_Overview.py reads live from metrics.json today.

Experiments

Six options, one identical answer, once.

Used to return the same prediction no matter which activity was picked (see Failures & Iterations below). Try it now: every option differentiates correctly against the actual fitted regressor.

dashboard/pages/4_Live_Inference.py, Calories Prediction tabget_activity_categories() sourced

Pick any activity from the dropdown a real visitor sees today. Inputs held constant: 8,000 steps, 130bpm, 7.5h sleep.

Predicted calories

201.3kcal

Correctly differentiated, 201 to 1,076 kcal depending on activity, for the exact same steps, heart rate and sleep inputs.

dashboard/utils.py exposes get_activity_categories(), a single source of truth read from the processed dataset. The dropdown, the baseline heuristic (the path most visitors actually hit, since models aren't downloaded by default) and the classifier's accuracy page all read from it, so this can't drift apart again the way it used to.

Honest limitation

Five personas, and very little signal to find.

Not a bug. A structural consequence of how the synthetic data was generated, worth stating in the same breath as the segmentation results, not after.

Histogram of per-user average daily steps across 1,959 users, narrowly distributed around the population mean

Per-user average daily steps, all 1,959 users

Generated for this case study directly from the processed dataset. Real matplotlib output, not a mockup.

The synthetic data generator assigns each user's daily activity independently and uniformly at random, no persona, no per-user bias, nothing that would make a habitually sedentary user stay sedentary across days. Over 183 days, per-user averages regress hard toward the population mean.

Measured directly: the coefficient of variation of avg_steps across all 1,959 users is 7.5%, mean 6,348, min 4,861, max 8,084. K-Means still returns 5 confidently labeled clusters, "Sedentary" through "High Performer", but with this little inter-user variance, it is separating small, largely noise-driven differences, not genuinely distinct behavioral archetypes.

Engineering decisions

Seven real decisions, not a generic checklist.

What was chosen, the constraint behind it, what it cost, and what actually happened, each verified against the running system.

decision_log.log7 real decisions
ArchitectureShipped

Preprocessing lives inside the model, not beside it

Context

Every scikit-learn pipeline bundles its own SimpleImputer, StandardScaler and (for the regressor) OneHotEncoder together with the estimator, fit and pickled as one object.

Trade-off
A separate, hand-maintained feature-transformation function on the serving sideA bug class closed by construction: dashboard/utils.py calls pipeline.predict(raw_features) directly, so training and serving cannot silently drift apart
Outcome

Verified directly: no second feature-engineering implementation exists anywhere in dashboard/.

Failures & iterations

Two fixed by the refactor. Three fixed here today.

The first two were closed by the project's own same-day rebuild, re-verified here. Three more, found during this case study's research pass, were fixed and covered by new regression tests in this same pass. The last is a data-generation limitation, not a bug, and has no code fix.

The test suite could not even import the project

Found

pytest -q tests/test_etl.py failed with ModuleNotFoundError: No module named 'src', per the project's own project_refactor.md.

Fix

Fixed by the project's Phase 2 refactor: path resolution centralized in src/config.py, sys.path bootstrapping added to entrypoints. Re-verified here: 16/16 tests pass.

Three overlapping scripts trained three different models

Found

train_cloud_models.py used n_estimators=30, max_depth=12, min_samples_leaf=10 aimed at a stated "<50MB" target, a different, never-reconciled answer to the same artifact-size problem the canonical script represents.

Fix

Fixed by Phase 1: the two non-canonical scripts moved to archive/legacy/, leaving src/models/training.py as the one path exercised by tests and by src/run_pipeline.py.

Every Live Inference calorie prediction returned the same number

Found

The dropdown offered Walking, Running, Cycling, Yoga, HIIT, Strength Training. The regressor's OneHotEncoder was fit on the real category values: cycling, gym_workout, hiking, running, swimming, walking, yoga, lowercase, snake_case, two of which (HIIT, Strength Training) never existed in training data at all. handle_unknown="ignore" silently zeroed every one of the six UI options, and the baseline heuristic (the actual default path most visitors hit) had the identical bug in its own hand-typed activity_factor dict.

Fix

Fixed: dashboard/utils.py now exposes get_activity_categories(), a single source of truth read from the processed dataset itself. The Live Inference dropdown and the baseline heuristic both consume it, closing both instances of the same bug. Covered by a new regression test that fits a fixture and asserts the dropdown options equal the fixture's real categories.

The dashboard's headline accuracy figures described a model that was never shipped

Found

"84% accuracy" and "R-squared = 0.91" on the Architecture tab matched, to 4 significant figures, the notebook's genuine Spark MLlib LogisticRegression/LinearRegression results. No code anywhere evaluated the Random Forest models that actually ship.

Fix

Fixed: src/models/training.py now runs an 80/20 held-out evaluation (stratified for the classifier) alongside fitting the shipped models on 100% of the data, and writes the result to dashboard/models/metrics.json. dashboard/1_Overview.py reads that file live: 89.3% held-out accuracy, R-squared 0.918, for the models actually in dashboard/models/, with real on-disk file sizes computed the same way rather than hardcoded.

A casing fix on one page never propagated to the page with the live bug

Found

dashboard/pages/3_Advanced_Modelling.py was updated, same day, to lowercase and strip both true and predicted labels before comparing accuracy. dashboard/pages/4_Live_Inference.py, which fed a capitalized string straight into the regressor's encoder, was never touched by that fix.

Fix

Fixed as part of F-03: both pages, and the baseline heuristic, now read activity labels from the same get_activity_categories() function, so the two pages can no longer drift apart the way they did before.

The "5 user personas" have very little real signal to separate

Found

The synthetic data generator assigns each user's daily activity independently and uniformly at random, no persona, no per-user bias. Over 183 days, per-user averages regress hard toward the population mean: coefficient of variation on avg_steps across 1,959 users is 7.5%.

Fix

Not a code bug, a data-generation limitation. K-Means still produces 5 labeled clusters, but with this little inter-user variance they mostly separate small, largely noise-driven differences rather than genuinely distinct behavioral archetypes.

Honest limits

What's still left open.

Stated limits
  • .The two live ETL entrypoints (Dockerfile vs. README Quickstart) still coexist; consolidating them was outside this pass's scope.
  • .The "5 user personas" segmentation limitation (below) is a data-generation property, not a code bug, and was left as-is: no amount of dropdown or metrics fixing changes how little inter-user variance the synthetic generator produces.
  • .Held-out accuracy/R-squared for the shipped Random Forests are now computed by the training script itself and written to dashboard/models/metrics.json on every run, rather than being a one-off number computed only for this case study.
What I learned

A dashboard is production surface area.

  • A dashboard's "Architecture" tab is production surface area, not decoration. It made falsifiable claims that needed the same verification as any other output, and once verified, the fix was to compute and display the real number, not just to relabel the claim as unverified.
  • A handle_unknown="ignore" encoder is safe against crashes and silent about drift at the same time. The actual fix wasn't retyping the dropdown's labels to match, it was collapsing two independent hand-typed label lists (a dropdown and a baseline heuristic dict) into one function so the same drift cannot reopen in only one of them.
  • A refactor's own audit document is a primary source. project_refactor.md gave a precise, dated account of the pre-refactor failure modes that would otherwise have needed git archaeology to reconstruct.
  • A synthetic-data generator encodes assumptions that downstream models inherit invisibly. A uniform-random, no-persona activity generator quietly sets the ceiling on what any later behavioral-segmentation model can find, and unlike the other two bugs, that one has no code fix.
See it for yourself

The dashboard is live, and the fix above shipped with it.

View live dashboardView source on GitHub