Skip to main content

data-cleaning

Use when a raw table is too dirty to trust — nulls, sentinels, duplicate rows, category sprawl, mixed types, bad dates — and you need a re-runnable clean() plus a schema gate that fails loud. NOT emitting .xlsx (that is spreadsheet-ops), NOT acquiring rows (that is data-scraper), NOT parsing PDF/HTML into rows (that is structured-extraction).

소스 정보

저장소
ericrisco/rsc-harness
최근 소스 활동
2026년 8월 4일 20:28
감지된 SKILL.md 언어
영어
스타
110
포크
9

설치 방법

기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.

소스 파일 검토

설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.

파일 탐색기
6 개 파일

SKILL.md 표시 중

SKILL.md
소스 지침 · 읽기 전용 미리보기
name
data-cleaning
description
Use when a raw table is too dirty to trust — nulls, sentinels, duplicate rows, category sprawl, mixed types, bad dates — and you need a re-runnable clean() plus a schema gate that fails loud. NOT emitting .xlsx (that is spreadsheet-ops), NOT acquiring rows (that is data-scraper), NOT parsing PDF/HTML into rows (that is structured-extraction).
tags
["data-cleaning","data-quality","pandas","pandera","validation","deduplication","reproducibility","polars"]
recommends
["duckdb","spreadsheet-ops","structured-extraction","data-scraper","analytics","business-intelligence","forecasting","python"]
profiles
[]
origin
risco
# Data cleaning — make dirty data trustworthy, and make the cleaning auditable A clean table is **typed + deduped + normalized + validated + reproducible**. The deliverable here is never "I opened a notebook and fixed some rows by hand." It is a re-runnable function `clean(raw) -> df` plus a **schema gate** that fails loud when next month's file violates the contract. Reproducible means the same input always yields the same output: versions pinned, sorts deterministic, nothing random without a seed. If you can't re-run it tomorrow and get the identical result, you haven't cleaned the data — you've edited a snapshot. Cleaning **starts** once you hold tabular rows and **ends** at a validated table/DataFrame/Parquet. Before that boundary the job is acquisition ([`data-scraper`](../data-scraper/SKILL.md), [`structured-extraction`](../structured-extraction/SKILL.md)); after it, consumption ([`spreadsheet-ops`](../spreadsheet-ops/SKILL.md), [`analytics`](../analytics/SKILL.md), [`business-intelligence`](../business-intelligence/SKILL.md), [`forecasting`](../forecasting/SKILL.md)). Multi-GB analytical SQL is an engine choice, not a cleaning one — [`duckdb`](../duckdb/SKILL.md). Current stack (verified 2026-06-02): **pandas 3.0.x** (3.0.0 shipped 2026-01-21) and **pandera 0.31.1** (supports pandas ≥ 3) for in-pipeline schema validation; **Polars** and **DuckDB** when pandas runs out of RAM. Pin them: `pandas==3.0.3`, `pandera==0.31.1`. ## The pipeline shape One canonical order. Each step is positioned for a reason, not by habit. ```python import pandas as pd def clean(raw_path: str) -> pd.DataFrame: df = read_typed(raw_path) # 1. read with explicit dtypes — never let pandas guess df = normalize(df) # 2. strings/categories/numbers/dates — collapse invisible variance df = dedupe(df) # 3. AFTER normalize+type, so "1"/1 and "US "/"US" actually collapse df = handle_missing(df) # 4. decide per column: drop / impute+flag / leave NA / quarantine df = Schema.validate(df, lazy=True) # 5. the GATE — fail loud, surface every violation at once return df ``` - **Type before dedupe** — otherwise `"1"` (string) and `1` (int) survive as two distinct keys. - **Normalize before dedupe** — `"US "` and `"US"` are the same customer; dedupe can't see that until whitespace/case are collapsed. - **Validate last** — it is the gate, not a cleaning step. It asserts the contract holds *after* all fixes. - **Write to a NEW artifact** — the raw file is read-only; you never overwrite your only source. ## Read it right The single most common reproducibility footgun: pandas' legacy numpy path silently casts an integer column containing one NaN to `float64`, so your `id` becomes `1001.0`. Control the dtype on read. ```python # BAD — pandas guesses: ids become floats, "N/A" stays a string, "" is sometimes NaN sometimes "" df = pd.read_csv("raw.csv") # GOOD — explicit, deterministic, real nullable types df = pd.read_csv( "raw.csv", dtype_backend="pyarrow", # real nullable ints/strings; no silent float-cast na_values=["", "N/A", "NA", "null", "-1", "999"], # YOUR sentinels become real NA keep_default_na=True, # keep pandas' default NA tokens too encoding="utf-8", # state it; don't let locale decide ) ``` Two pandas 3.0 facts that read depends on. `dtype_backend="pyarrow"` only works if pyarrow is actually installed — PDEP-14 deliberately kept a NumPy-object fallback so PyArrow stays *recommended, not required* — so `pip install pyarrow` for the faster backed path, or pass `dtype_backend="numpy_nullable"` when it is absent. And the default `str` dtype (PyArrow-backed when pyarrow is present, NumPy-object-backed otherwise) uses **`NaN` missing-value semantics** like every other default dtype: test for null with `pd.isna()`, never by comparing against whichever null token happened to appear. ## Profile before you fix Let the numbers drive the plan, not a glance at `df.head()`. Run this first, every time. ```python def profile(df: pd.DataFrame) -> pd.DataFrame: return pd.DataFrame({ "dtype": df.dtypes.astype(str), "null_pct": (df.isna().mean() * 100).round(1), "n_unique": df.nunique(dropna=True), # cardinality — catches category sprawl "sample": df.apply(lambda s: s.dropna().unique()[:3].tolist()), }) print(profile(df)) print("rows:", len(df), "exact dupes:", df.duplicated().sum()) ``` A column at 90% null is a drop candidate; one with 400 distinct "countries" needs a mapping table; an "age" with min `-1`/max `999` has sentinels to map. The profile is your TODO list. ## Normalize Each fix below: **Bad → Good**, with a one-line why. **Strings** — invisible variance (trailing space, mixed case, lookalike unicode) silently breaks joins and dedupe. ```python # BAD: "US ", "us", "us" all look different to a join # GOOD: s = df["country"].str.strip().str.casefold().str.normalize("NFKC") ``` **Categories** — use a **mapping table**, never a tower of regex. A dict is auditable and an *unmapped* value gets quarantined instead of silently passing through. ```python COUNTRY = {"usa": "US", "u.s.": "US", "united states": "US", "u.s.a.": "US", "es": "ES", "españa": "ES"} key = df["country"].str.strip().str.casefold() df["country"] = key.map(COUNTRY) # unmapped -> NA, which the gate below will catch (no silent pass) ``` **Numbers** — turn sentinels into NA, then choose a range policy explicitly: *clip* (cap to bound) when out-of-range is plausibly a recording cap, *reject* (→ NA / quarantine) when it is impossible. ```python df["age"] = df["age"].mask(df["age"].isin([-1, 999])) # sentinels -> NA df["age"] = df["age"].clip(lower=0, upper=120) # clip policy; or .mask(~df["age"].between(0,120)) to reject ``` **Dates** — state the `format`, coerce, then **count the casualties**. Never trust dayfirst inference; `03/04/2026` is ambiguous and pandas will pick silently. ```python parsed = pd.to_datetime(df["signup"], format="%Y-%m-%d", utc=True, errors="coerce") bad = parsed.isna() & df["signup"].notna() assert bad.sum() == 0, f"{bad.sum()} dates failed the expected format — inspect before proceeding" df["signup"] = parsed ``` Copy-paste versions of all of these — category mapping with unmapped→quarantine, a robust date parser, unicode/encoding repair, a sentinel→NA table, numeric clip-vs-reject, plus Polars equivalents — are in [references/normalization-recipes.md](references/normalization-recipes.md). ## Dedupe `drop_duplicates(keep="first")` is meaningless without a defined key and a stable sort — "first" of what order? Define both. ```python key = ["customer_id"] # the BUSINESS key, stated explicitly df = (df.sort_values(["customer_id", "updated_at"], ascending=[True, False], kind="stable") .drop_duplicates(subset=key, keep="first")) # keep most-recent per customer, deterministically ``` Near-duplicates (`"Acme Inc"` vs `"Acme, Inc."`) are a *normalization* problem — collapse them in the normalize step first; only then does exact dedupe catch them. Fuzzy matching is a separate, riskier decision — make it visible, never automatic. ## Missing values — decide per column No silent `fillna(0)`: a zero is a value, and treating "unknown" as zero poisons every mean, sum, and model downstream. Pick deliberately. | Situation | Action | Why | | --- | --- | --- | | Column is mostly null (e.g. >70%) and not load-bearing | Drop the **column** | Imputing it invents signal that isn't there | | A few rows missing a *required* key (id, date) | Drop the **row** (and log/quarantine) | Can't dedupe or join without the key | | Numeric gap you must fill for a model | Impute **and add a `_was_missing` flag** | The model can learn "was missing"; you keep the audit trail | | Genuinely optional field | **Leave NA** | NA is information; don't fabricate a value | | Value is present but *invalid* (unmapped category, bad date) | **Quarantine the row** | Don't drop silently and don't let it pass the gate | ```python df["income_was_missing"] = df["income"].isna() df["income"] = df["income"].fillna(df["income"].median()) # impute + flag, never bare fillna(0) ``` ## Validate — the gate This is where cleaning becomes *trustworthy*. Declare the contract as a pandera `DataFrameModel`, validate **output** (and input expectations where they exist), and split valid rows from failures instead of crashing — the failures become your quarantine. ```python import pandera.pandas as pa from pandera.typing import Series class CustomerSchema(pa.DataFrameModel): customer_id: Series[int] = pa.Field(unique=True, ge=1) country: Series[str] = pa.Field(isin=["US", "ES", "FR"]) # only mapped categories survive age: Series[float] = pa.Field(ge=0, le=120, nullable=True) signup: Series[pa.DateTime] = pa.Field(nullable=False) class Config: strict = True # reject unexpected columns coerce = True # coerce to declared dtype, fail loud if impossible # lazy=True collects EVERY violation at once instead of dying on the first try: valid = CustomerSchema.validate(df, lazy=True) except pa.errors.SchemaErrors as e: failures = e.failure_cases # dataframe of exactly which rows/checks failed failures.to_parquet("quarantine.parquet") # keep, don't drop — someone investigates these valid = df.drop(index=e.failure_cases["index"].dropna().unique()) # proceed with the clean subset ``` `coerce=True` fixes types the contract expects; `nullable` states which columns may hold NA; field `Check`s (`ge`, `le`, `isin`, `unique`) are the allowed-value rules. `strict` catches columns that shouldn't be there. Together they are the data contract in code. Log the row-count diff on every run — `in`, `out`, coerced, quarantined — so what the pipeline changed is an auditable record, not an assumption. When to escalate beyond pandera: reach for **GX Core 1.0** (Great Expectations' rebranded OSS — Data Context → Data Source → Expectation Suite → Validation Definition → Checkpoint) when you need a *shared data-quality platform* across many datasets and teams with a results store and docs. Use **dbt model contracts** (enforced at build) plus **dbt tests** (post-materialization) when the cleaning lives in a SQL warehouse, not Python. The full `DataFrameModel` (custom `@pa.check`, lazy `SchemaErrors` report, valid/quarantine split helper), the GX checkpoint sketch, the dbt model-contract + `data_tests` YAML, and the "which validator" chooser are in [references/validation-patterns.md](references/validation-patterns.md). ## Scale — when pandas hurts Heuristic: pandas is fine while the data fits comfortably in RAM (roughly ≤ 1–2 GB working set). Beyond that, or when a groupby/join dominates the runtime, switch the *mechanics* (not the principles): - **Polars** for clean-at-scale: `pl.scan_csv(...)` (lazy, parallel, Rust), then `.unique()`, `.drop_nulls()`, `.fill_null(...)`, `.str.*` — the same profile→normalize→dedupe→validate shape, faster. pandera validates Polars frames too, and the [recipes reference](references/normalization-recipes.md) has the Polars equivalent of every fix above. - **DuckDB** when the bottleneck is analytical SQL over multi-GB files — point heavy joins/aggregations there: [`duckdb`](../duckdb/SKILL.md). It is an *engine* choice; correctness/normalization is still this skill's job. ## Anti-patterns | Anti-pattern | Why it breaks | | --- | --- | | "`fillna(0)` to get rid of the nulls" | Zero is a value; it distorts every mean/sum/model. Impute deliberately and add a `_was_missing` flag. | | "`drop_duplicates()` — done" | No `subset`, no sort → which row survives is nondeterministic. Define the key, `sort_values(kind="stable")`, set `keep`. | | "`pd.read_csv(path)` and start cleaning" | pandas guesses: ids become floats, dates become strings. Pass `dtype_backend` + `na_values`. | | "I fixed the rows in a notebook cell" | Not reproducible — next month's file gets nothing. Wrap it in `clean(raw) -> df`. | | "Drop the rows that look wrong" | Silent data loss with no audit trail. Quarantine to a file; someone investigates. | | "A few regexes will normalize the countries" | Unmaintainable and silent on new values. Use a mapping dict; unmapped → NA → caught by the gate. | | "`pd.to_datetime` figures out the format" | Ambiguous dates parse silently wrong. State `format=`, `errors="coerce"`, then assert the NaT count. | | "Validation passed, so we're good" | A gate that never fails is a no-op. Feed it a known-bad row and confirm it *rejects*. | | "It's slow, rewrite everything in Polars" | Switch the engine, not the discipline — profile→normalize→dedupe→validate still applies. | ## Verify `scripts/verify.sh` runs from anywhere, no network. It does static structure checks on this skill (frontmatter keys, references present) always, and — when pandas + pandera are installed — extracts the documented pattern, feeds it one clearly-good row and one clearly-bad row, and asserts the good row PASSES validation while the bad row is FLAGGED/quarantined, proving the gate is not a no-op. Without pandas/pandera it prints SKIP for the runtime check and still passes the static checks. ## Project grounding (02-DOCS + CLAUDE.md) In a project with a `02-DOCS/` layer (the [`harness`](../harness/SKILL.md) wiki), record this dataset's cleaning decisions — the schema/contract, the category mapping tables, the dedupe key, the quarantine location, version pins — in `02-DOCS/wiki/data/<dataset>.md`, link it from the root `CLAUDE.md` `## Knowledge map`, and read it first on every re-run so the contract stays consistent. No `02-DOCS/`? Skip silently. Conventions are recorded, never gated.
GitHub에서 보기