Projects / 02

← Home
2026 · Python · Data Quality · Streamlit

Data Quality Framework

A local web app that audits a messy dataset. Upload a CSV, Excel, JSON, or XML file and it profiles the data, finds quality problems, checks it against validation rules, and returns a prioritized list of what to fix. It never changes your data and never touches the network.

role
Sole designer and developer
timeline
September 2026
tools
Python, Pandas, SQL, Great Expectations, Streamlit, DuckDB
type
Personal project
01 — context

Every dataset starts dirty

Real-world data arrives messy, and the first step of any analysis is working out what is wrong with it and how much. Doing that by hand means writing the same one-off checks for every new file, which is slow and easy to get wrong by omission — you only catch the problems you thought to look for.

02 — problem

The checks nobody writes twice

Null counts, duplicate keys, outliers, schema drift, broken referential integrity, cross-column contradictions, categorical values that are the same thing spelled three ways. Every analyst rewrites these checks per project, and each rewrite is a chance to miss one. The gap is not knowing how to fix dirty data; it is knowing what is wrong before you commit to a fix.

03 — process

Constraints first, then the build

The design is shaped by what the tool refuses to do as much as by what it does.

Decision 1 — audit only, never modify

Whether to impute a null or merge two categories depends on what the data means, and the fix is trivial once you know what to fix. Automating it risks a silent wrong fix; flagging it costs the user one deliberate step. A test asserts the uploaded data is never modified.

Decision 2 — the user confirms the types

Inferring dtypes from values alone gets predictable cases wrong, like ZIP codes with leading zeros read as integers. The tool guesses, then puts the guess in front of the user to confirm or override before anything downstream depends on it.

Decision 3 — local only, no network

No API calls, no hosted services, no outbound requests at runtime. Upload is the only way data gets in, which keeps it private and the tool free to run.

Decision 4 — Great Expectations for rules

Great Expectations is an open-source validation library: you declare what the data is supposed to look like as a suite of rules, run the data against it, and get back a structured verdict on each one. That declarative model is why I chose it. The alternative is scattering assertions through analysis code, where the rules are invisible unless you read the script and unusable by anyone who can’t.

Concretely, it gives the user rule types they can pick rather than program — not-null, unique, between, in-set, regex, row-count — applied to any column, which is what makes custom rules possible for someone who does not write Python. Those sit in the same suite as the rules I auto-generate and run together, so the audit is one thing rather than two. Each rule returns pass or fail with the row count and percentage that broke it, which is what feeds the prioritized action list. Rules that crash on a bad regex or a missing column are recorded as errors rather than failures, which is where the ERROR status comes from. And it runs in memory, writing nothing to disk, which keeps the tool local.

Decision 5 — Pandas plus SQL, with two engines

Statistical checks run in Pandas. Constraint-style checks — uniqueness, foreign-key-style joins, cross-column consistency — run as real SQL, with DuckDB handling big files and SQLite small ones. Two engines add complexity, so I convert types before SQL and tested that both give identical results.

Decision 6 — rules with tolerance, and consistent definitions

Auto-generated rules use IQR-based ranges, because a rule fit exactly to the data would pass everything and show nothing. The null check, the outlier check, and the rules share the same definitions, so they cannot disagree.

Decision 7 — flag, don’t fix, for fuzzy findings

Typo matching runs on Levenshtein distance, counting the fewest single-character insertions, deletions, and substitutions needed to turn one value into another. I implemented it directly rather than pulling in an external library, and capped the distance at two so it stays conservative.

Even so it has false positives — poor and good, active and inactive — so each candidate is confirmed or dismissed by the user rather than resolved automatically.

Decision 8 — one prioritized action list

Anomaly failures and failed rules merge into a single list sorted by rows affected, with duplicates removed. Rules that cannot run get their own ERROR status instead of looking like a failure.

04 — solution

Upload a file, get a prioritized list

A local Streamlit app taking CSV, XLSX, XLS, JSON, or XML. It runs a fixed pipeline — ingestion, schema confirmation, profiling, anomaly detection, validation rules, recommended actions, reporting — and returns a full anomaly profile covering nulls, duplicates, outliers, schema drift, referential integrity breaks, cross-column consistency violations, categorical inconsistencies, and typos caught by Levenshtein distance.

Auto-suggested Great Expectations validation rules come out of the data itself and stay editable by hand. Everything merges into one prioritized action list sorted by rows affected: the column, how many rows it touches, why it was flagged, and one sentence on what to do. Results export as a markdown action list, a styled HTML report, and an Excel scorecard.

05 — outcome

In use as the first pass

It serves as the initial pass on any new dataset, replacing ad-hoc per-project checks with one consistent audit. The SQL engine switches from SQLite to DuckDB past 100,000 rows, with the tool tested to 500,000.

2025 · Python · Signal Processing · Machine Learning

Heartifact

Heart disease is the leading cause of death worldwide, and the tools that detect it are slow, invasive, or need a specialist. Heartifact is an attempt at the opposite: detecting signs of arrhythmia or myocardial infarction from a short segment of ECG — potentially a single heartbeat — cheaply enough to run on a wearable.

role
Preprocessing, modeling, evaluation
timeline
December 2024 – March 2025
tools
Python, wfdb, SciPy, Pandas, Scikit-learn, Matplotlib
team
Darren Lam, Justin Yee, Joshua Wei
01 — context

Arrhythmia hides in the waveform

Around 5% of Americans experience an arrhythmia or myocardial infarction at least once. Detection today means blood tests, MRIs, CT scans, or cardiac catheterization: slow, requiring trained staff, or invasive, and largely unavailable where medical access is limited. An ECG is the cheap alternative, and the abnormalities that matter — premature ventricular contractions, ventricular escape beats, bundle branch blocks, fusion beats — are visible as distortions in the shape of individual beats.

We worked from the MIT-BIH Arrhythmia Database on PhysioNet: 47 subjects recorded at the Beth Israel Hospital Arrhythmia Laboratory between 1975 and 1979, roughly 30 minutes each, two-channel ambulatory ECG at 360 Hz and 11-bit resolution over a 10 mV range, annotated beat by beat by cardiologists. It is old, and it is still the benchmark dataset in cardiac signal processing. A three-person team project for ECS 111 at UC Davis.

02 — problem

Raw physiological signal is not model-ready

A literature review showed the gap we were aiming at. Bedin et al. (2024) reconstruct noisy ECG with diffusion models but do not target arrhythmia or infarction detection. Wang et al. (2022) classify cardiac anomalies with a hierarchical GAN, accurately but at a computational cost that rules out wearables. Tadesse et al. (2021) built DeepMI for infarction detection and timing, but it requires multi-lead ECG, which single-lead wearable monitors cannot supply. Nothing addressed accurate detection from minimal, single-lead input.

The data posed its own problem. Raw recordings carry baseline drift and high-frequency noise that swamp the beat morphology a classifier needs, amplitudes vary between patients and recordings, and annotated beats skew heavily toward normal rhythm. Which model performs best is unanswerable until every beat has been processed identically.

03 — process

Build the pipeline before the model

We built the workflow end to end in Python: ingestion with wfdb, then signal cleaning, then windowing, then normalization, then model experimentation, so every model saw exactly the same inputs.

Decision 1 — band-pass over high-pass

A 4th-order Butterworth high-pass at 0.5 Hz removes baseline drift but leaves high-frequency noise. We compared it against a 0.5–40 Hz band-pass and used the band-pass, which keeps the QRS complex intact while cutting both ends. Fast Fourier transforms on sample beats confirmed where the meaningful frequency content actually sat.

Decision 2 — fixed windows centered on the QRS complex

Rather than feeding whole recordings to a model, we extracted 200-sample windows centered on each annotated QRS peak, about 555 ms per beat. That turns a continuous signal into a fixed-width feature vector and puts the diagnostic event in the same place every time.

Decision 3 — normalize per beat, balance by undersampling

Each window was z-scored individually so amplitude differences between patients did not become signal. We then undersampled to 10,000 abnormal and 10,000 normal beats, which both balanced the classes and kept the dataset reproducible.

04 — solution

Four models on identical inputs

Preprocessing and modeling were split into reusable modules so experiments could be re-run without rebuilding the dataset. On an 80/20 split we implemented and compared four supervised classifiers: k-nearest neighbors, support vector machine, logistic regression, and random forest, evaluating each on accuracy, confusion matrix, and per-class precision and recall rather than accuracy alone.

05 — outcome

Random forest, 99.05%

Random forest was the strongest approach. Ten estimators already reached 98.7% accuracy; tuning to 1,000 brought it to 99.05%, with 12 false positives and 26 false negatives out of 4,000 held-out beats and 0.99 precision and recall on both classes. We had set a target of keeping false negatives under 5%, on the reasoning that missing a cardiac event costs more than a false alarm; the final model missed 1.3% of them.

The comparison, not the single number, was the deliverable: with consistent preprocessing, an ensemble over raw beat morphology outperforms the distance- and margin-based alternatives on this data. Where it goes next is real-time inference on streaming segments, narrowing the input window toward a single heartbeat, and edge deployment on devices like portable ECG monitors and smartwatches.

2023 · Python · Tableau · Marketing Analytics

Cyclistic Case Study

A Chicago bike-share company needed to turn casual riders into annual members. I analyzed twelve months of trip data to find where the two groups actually diverge, and built the marketing recommendations from those differences.

role
Junior data analyst, marketing analytics
timeline
September – December 2023
tools
Python (Pandas, NumPy), SQL, Tableau
data
12+ months of historical ride records
01 — context

Chicago, 5,824 bikes, two kinds of rider

Cyclistic runs a Chicago bike-share network of 5,824 geotracked bicycles across 692 stations, sold as single-ride passes, full-day passes, and annual memberships. Pass buyers are casual riders; the rest are members. Finance had concluded members are considerably more profitable, and marketing had until then relied on broad general-awareness campaigns. The capstone for the Google Data Analytics Certificate, worked through the Ask, Prepare, Process, Analyze, Share, and Act framework.

02 — problem

Convert, don’t acquire

The director of marketing set the goal as converting existing casual riders into annual members rather than chasing new customers, on the reasoning that casual riders already know the service and have chosen it. That left three questions to answer with data: how members and casual riders use the bikes differently, why a casual rider would buy a membership, and how digital media could push them to. Recommendations had to survive executive review, so they needed visualizations behind them.

03 — process

Compare behavior across a full year

I worked through twelve months of historical trip data in Python and SQL, comparing the two groups on trip duration, time of day, day of week, and month rather than treating casual riders as one undifferentiated audience. Covering a full year mattered: the sharpest difference between the groups only appears when you look across seasons.

04 — solution

Commuting versus leisure

The split was consistent. Members ride steadily all year, including through January and February, which points to commuting and regular travel. Casual riders peak in June through August and fall off in autumn and winter, and take longer trips concentrated on weekends, which points to leisure, tourism, and recreation.

The recommendations followed from that: seasonal promotions and trial memberships timed to the summer peak, cost comparisons showing frequent casual riders what repeated pass purchases add up to against an annual fee, weekend pricing aimed at the riders already using the service most, and personalized email offers triggered by ride frequency rather than blanket campaigns.

05 — outcome

A targeted conversion case

The analysis replaced a broad-awareness strategy with a segment-level one, projecting a lift in casual-to-member conversion by concentrating spend on casual riders whose behavior already resembled members. Delivered with the visualizations and reasoning needed for executive approval.