Recipe Data Quality moarstats - dathere/qsv GitHub Wiki
Recipe: Data-Quality Signals with moarstats
Tier: Intermediate
Commands used: moarstats, frequency, replace, sort, luau
Anchor dataset: examples/viz/nyc_capital_projects.csv: 12,587 NYC capital projects (a sample of NYC Open Data's Capital Projects Database) with three budget columns
Requires: qsv 24.0.0+ (most measures used here are new in 24.0.0)
Problem
Before you analyze a table, you want to know whether its numbers can be trusted:
- Were the amounts typed in, rounded, or made up?
- Is "zero" a real value, a placeholder, or several different spellings of one?
- Are the extremes real outliers, or is the standard deviation too inflated to tell?
- Is the file sorted or clustered, so that
headgives a biased sample? - Are two columns the same fact encoded twice?
stats gives you the mean, standard deviation and min/max, but those are the numbers that heavy tails and bad encodings distort. moarstats adds measures built for these questions. 24.0.0 added 17 of them, and this recipe uses them as a data-quality checklist.
Recipe: Stats → Insights covers the full stats → moarstats → pragmastat → describegpt pipeline. This recipe stays on the quality checks.
Data
# bundled with qsv
cp examples/viz/nyc_capital_projects.csv capital.csv
Outside the qsv repo:
curl -L -o capital.csv https://raw.githubusercontent.com/dathere/qsv/master/examples/viz/nyc_capital_projects.csv
The columns are maprojid, magencyacro, magencyname, typecategory, totalplannedcommit, commit_total and spent_total.
Solution
1. Compute everything once
qsv moarstats --advanced capital.csv
# Stats CSV file not found: .../capital.stats.csv
# Computing baseline stats...
# Added 76 additional statistics columns to .../capital.stats.csv
With no capital.stats.csv present, moarstats runs stats first and then appends its columns to that file. --advanced adds the measures that need every value: moment skewness, L-moments, lag-1 autocorrelation and Benford MAD. On this file it takes well under a second.
Each check below reads columns from capital.stats.csv.
2. Zeros: zero share vs. Berger-Parker dominance
qsv select field,nullcount,n_zero,n_negative,zero_share,berger_parker_dominance,mode capital.stats.csv \
| qsv search -s field total | qsv table
field nullcount n_zero n_negative zero_share berger_parker_dominance mode
totalplannedcommit 0 3222 179 0.256 0.256 0
commit_total 21 7495 0 0.5965 0.4594 0
spent_total 21 5689 0 0.4527 0.452 0
zero_shareis the share of non-null numeric values that are exactly zero. 60% of projects have no commitment recorded at all, so any mean or total overcommit_totalis mostly zeros.berger_parker_dominanceis the share of rows held by the most frequent value. The mode is found by matching the text exactly, so0and0.00count as different values.
For commit_total the two disagree (0.5965 vs. 0.4594). If the mode is zero, they should match. frequency shows why they don't:
qsv frequency -s commit_total -l 3 capital.csv | qsv table
field value count percentage rank
commit_total 0 5783 46.02101 1
commit_total 0.00 1552 12.35079 2
commit_total 0.0 160 1.27328 3
commit_total Other (5,005) 5071 40.35493 0
commit_total (NULL) 21
Zero is written three ways. It makes no difference to arithmetic, but it breaks every text-based operation, including dedup, joins on the value, frequency and search. Normalize it:
qsv replace -s commit_total,spent_total '^0\.0+$' '0' capital.csv -o capital_fixed.csv
# 1712
qsv moarstats capital_fixed.csv
qsv select field,zero_share,berger_parker_dominance capital_fixed.stats.csv | qsv search -s field total | qsv table
field zero_share berger_parker_dominance
totalplannedcommit 0.256 0.256
commit_total 0.5965 0.5955
spent_total 0.4527 0.452
The remaining gap is the 21 nulls. zero_share divides by non-null values and Berger-Parker divides by all rows.
n_negative also shows 179 negative planned commitments. They could be legitimate de-obligations or entry errors, and either way they are worth asking the publisher about.
3. Outliers: robust spread vs. standard deviation
qsv select field,stddev,mad_normalized,iqr_normalized,max_zscore,robust_max_zscore,robust_min_zscore capital.stats.csv \
| qsv search -s field total | qsv table
field stddev mad_normalized iqr_normalized max_zscore robust_max_zscore robust_min_zscore
totalplannedcommit 117585520.0608 544115.0142 2633101.5401 39.1709 8492.3424 -51.9578
commit_total 31116974.3737 0 142151.4189 61.8178
spent_total 55192274.7786 119467.857 1217649.2671 51.4887 23840.2421 -0.6745
mad_normalized(MAD/0.6745) andiqr_normalized(IQR/1.349) both estimate sigma, and for normal data they agree withstddev. Herestddevis more than 200×mad_normalized: a few huge projects set the spread, not the typical one.- Because those projects inflate
stddev, ordinary z-scores understate them. The largest planned commitment has amax_zscoreof 39. Itsrobust_max_zscore(the Iglewicz-Hoaglin modified z-score) is 8,492. - The most negative planned commitment has a
min_zscoreof only −0.37, so it looks ordinary. Itsrobust_min_zscoreof −51.96 is far beyond the ±3.5 that the help text gives as the usual outlier flag. - When the median is zero the MAD is zero too, so
mad_normalizedis 0 and the robust z-scores are left empty, as forcommit_total. Useiqr_normalizedfor that column instead.
4. Shape: L-moments when moments break down
qsv select field,moment_skewness,kurtosis,l_cv,l_skewness,l_kurtosis capital.stats.csv \
| qsv search -s field total | qsv table
field moment_skewness kurtosis l_cv l_skewness l_kurtosis
totalplannedcommit 22.6671 660.7657 0.929 0.8813 0.7679
commit_total 41.7436 2072.9999 0.9582 0.9255 0.839
spent_total 28.6198 1114.4807 0.9297 0.8848 0.7719
Moment skewness and kurtosis are unbounded and driven by the few largest values, so numbers like 2,073 are hard to compare or interpret. The L-moment ratios are bounded and outlier-resistant:
l_skewness(τ3) lies in [−1, 1]. Values near 0.9 mean the data is almost as right-skewed as possible.l_kurtosis(τ4) is about 0.1226 for a normal distribution. All three columns are above 0.76, so their tails are extremely heavy.
5. Benford's law: were these numbers measured or made up?
qsv select field,min,max,benford_mad capital.stats.csv | qsv search -s field total | qsv table
field min max benford_mad
totalplannedcommit -27904000 4621178000 0.0052
commit_total 0.0 1925842132.58 0.005
spent_total 0.0 2848223215.5 0.0033
benford_mad is the mean absolute deviation between a column's first-digit proportions and Benford's law. The help text gives Nigrini's bands: < 0.006 close conformity, < 0.012 acceptable, < 0.015 marginal, otherwise nonconformity. All three budget columns conform closely, which is what you expect from amounts that come out of real transactions.
For contrast, here is a constructed column of 2,000 invented amounts, drawn uniformly between 100 and 99,999. Picking numbers "at random" spreads the first digits evenly, which is typical of made-up figures:
(echo id; seq 1 2000) > ids.csv
qsv luau map invented -B 'math.randomseed(42)' 'math.random(100, 99999)' ids.csv > invented.csv
qsv moarstats --advanced invented.csv
qsv select field,min,max,benford_mad,lag1_autocorrelation invented.stats.csv | qsv table
field min max benford_mad lag1_autocorrelation
id 1 2000 0.0597 0.9985
invented 111 99968 0.0608 -0.0191
invented scores 0.0608, about ten times the real columns and well into nonconformity. id fails too (0.0597), but that tells you nothing: Benford's law applies to naturally occurring magnitudes, not to assigned numbers. IDs, ZIP codes, phone numbers, sequence numbers and values with a hard floor or ceiling (such as "payments over £25,000") are expected to fail. Read a high MAD as a reason to look closer, not as proof of fabrication.
benford_mad is only emitted for numeric columns with at least 100 non-zero values spanning at least two orders of magnitude. With math.random(1000, 99999) the range is just under 100×, so the column gets no benford_mad at all. A blank value can mean the column didn't qualify.
6. File order: lag-1 autocorrelation
qsv select field,sortiness,lag1_autocorrelation capital.stats.csv | qsv search -s field total | qsv table
field sortiness lag1_autocorrelation
totalplannedcommit 0.1065 0.4319
commit_total 0.4284 0.0597
spent_total 0.2866 0.0473
lag1_autocorrelation correlates each value with the next one in file order. For totalplannedcommit it is 0.43, even though sortiness says the column is barely sorted (0.11). Similar values sit next to each other in blocks without the column being sorted. Shuffle the rows and the effect disappears:
qsv sort --random --seed 42 capital.csv > shuffled.csv
qsv moarstats --advanced shuffled.csv
qsv select field,lag1_autocorrelation shuffled.stats.csv | qsv search -s field total | qsv table
field lag1_autocorrelation
totalplannedcommit -0.0004
commit_total 0.0148
spent_total -0.0003
In practice, the first N rows of this file are not a fair sample. Use qsv sample rather than head or slice when you take a subset to explore or test with. In the constructed example above, the sequential id column scores 0.9985, which is what a sorted column looks like.
7. Categories: dominance and redundant columns
qsv select field,cardinality,berger_parker_dominance,mode capital.stats.csv | qsv search -s field -v total | qsv table
field cardinality berger_parker_dominance mode
maprojid 12587 0.0001
magencyacro 29 0.2194 DPR
magencyname 29 0.2194 Department of Parks and Recreation
typecategory 4 0.5738 Fixed Asset
Berger-Parker works for every type. One agency, Parks, holds 22% of projects, and one project type holds 57% of rows. Any "by agency" chart is dominated by Parks.
Then compare columns pairwise:
qsv moarstats capital.csv --bivariate -S cramersv,regression
qsv table capital.stats.bivariate.csv
field1 field2 cramers_v regression_slope regression_intercept r_squared n_pairs
magencyacro magencyname 1 12587
magencyacro typecategory 0.3748 11528
magencyacro totalplannedcommit 0.6387 12587
magencyacro commit_total 0.5797 12566
magencyacro spent_total 12566
...
totalplannedcommit commit_total 0.7275 0.1411 105239.7937 0.2848 12566
totalplannedcommit spent_total 0.0906 5061736.5148 0.0373 12566
commit_total spent_total 0.3571 5637717.1255 0.0405 12566
-
Cramér's V = 1 between
magencyacroandmagencynamemeans each acronym maps to exactly one name. The two columns hold the same fact twice, so keep one of them. -
Cramér's V on an amount column needs care. With thousands of distinct values, each cell of the contingency table is sparse, and V comes out high without meaning much.
spent_totalhas 6,828 distinct values, which is over the default--cardinality-threshold(half the row count), so its V is skipped. To compute V only between true categories, pass-C 100:qsv moarstats capital.csv --bivariate -S cramersv -C 100 qsv search -s cramers_v . capital.stats.bivariate.csv | qsv table # magencyacro magencyname 1 12587 # magencyacro typecategory 0.3748 11528 # magencyname typecategory 0.3748 11528 -
regressionfitsfield2onfield1. Committed amounts track the plan only loosely (r² = 0.28), and spending hardly tracks either (r² ≈ 0.04). The three budget columns measure different stages and are not nested. If you assumedspent ≤ committed ≤ planned, check it before relying on it.
Variations
Quick triage table for any file
qsv moarstats --advanced data.csv
qsv select field,type,zero_share,berger_parker_dominance,robust_max_zscore,l_skewness,lag1_autocorrelation,benford_mad \
data.stats.csv | qsv table
Read across the row for each column. A zero share that doesn't match Berger-Parker suggests inconsistent encoding. A large robust_max_zscore suggests outliers, an l_skewness near 1 means a heavy right tail, a high lag1_autocorrelation means order effects, and a benford_mad ≥ 0.015 (on naturally occurring amounts) is a reason to look closer.
Chart it
Run moarstats before viz smart --smarter. The dashboard then uses moment_skewness for skew hints and hoover_index and Gini for its Lorenz panels.
Pre-baked baseline
moarstats reuses an existing capital.stats.csv. Build it yourself with qsv stats --everything --stats-jsonl to control the percentiles and other options, or pass --stats-options.
Performance notes
- Everything except
--advancedcomes from the stats cache, with no extra pass over the data. --advancedreads every value of every numeric column. It took well under a second here, and it scales with the size of the file.--bivariateneeds an index, which is created automatically if missing. It is multithreaded, and--bivariate-batchbounds its memory on wide files.- After a qsv upgrade, a
.stats.csvwritten by the older version is recomputed automatically, so you never read old formulas' values.
Caveats
- These measures are signals, not verdicts. A Benford failure, a high lag-1 value or a dominant category each have innocent explanations, so confirm with the data owner.
- The Benford bands are Nigrini's for first digits. They assume many values spanning several orders of magnitude and coming from a natural process.
- Robust z-scores are undefined when the MAD is 0, which happens whenever more than half of a column's values are equal.
moment_skewness,kurtosis, Jarque-Bera and the bimodality coefficient changed in 24.0.0 to match scipy. Values from 23.0.1 and earlier are not comparable (details).
See also
- Aggregation & Statistics → moarstats: the full list of 73 measures and what 24.0.0 changed
- Recipe: Stats → Insights: from stats to an LLM-written data dictionary
- Recipe: Clean & Normalize: fixing what these checks find
- Stats Cache & Caching
docs/help/moarstats.md: the definition of every measure, with references