learn-python-data-analysis-with-phoebe / Session 4 of 8
Learn Python Data Analysis with Phoebe · Session 4 of 8

Cleaning messy data

This is the session everything before was building toward. You'll see the mess honestly with missingno, decide drop-versus-impute like an analyst instead of a guesser, coerce broken types, tame free-text titles, and turn the split salary columns into one honest yearly number. You walk out with a clean frame you can actually explore next week.

🟠 Getting real Builders The 70% missing problem 45 min live + self-study
0-3 · Welcome 3-18 · Concepts 18-40 · Clean the frame 40-45 · Q&A
Part 0

Real data is dirty, and that's the job

People imagine analysis is clever models and pretty charts. In reality, cleaning is roughly 80% of the work - and it's where analyses go wrong most quietly. A dropped-vs-filled decision made without looking, a salary column mixing hourly and yearly pay, a dtype that's secretly text: none of these throw an error. They just hand you a confident, wrong answer. Today we do it deliberately. We look first with missingno, decide with reasons, and only then touch the data. By the end you have a cleaned frame you can trust - and Session 5 explores it.

Live - presented in session Self-study - read after class ★ Build-along demo The project: the data job market
★ What you walk out with today A cleaned DataFrame with a single numeric salary_year column (min/max combined, pay period normalized to annual), sane dtypes across the board, tidy lowercased titles, no duplicate postings, and - just as important - a written record of what you dropped and why. Saved to a new file, raw untouched, ready to explore.
Part 1 · covers missingno + profile-before-fix

See the missingness first 5 min live

Before you drop a single row or fill a single gap, look at the shape of what's missing. The rule this course keeps hammering: profile before you fix. missingno turns a wall of NaNs into three pictures that tell you what to do.

Missingness matrix - each column is a field, each row a posting job_id title company location min_sal max_sal posted ~70% white = missing solid = value present The salary columns go white together - and that pattern decides your whole strategy.
🔍 Click to zoom - a missingno matrix: solid blocks are present values, white gaps are missing, and the salary columns are mostly white
LiveThe three missingno plots3 min

missingno gives you three complementary views. You run all three in about ten seconds and they answer different questions:

  • msno.matrix(df) - the row-by-row view above. White streaks show where gaps fall. If missingness clusters in blocks, something structural is going on; if it's random static, that's a different story.
  • msno.bar(df) - one bar per column showing the count of present values. The fastest way to rank columns from cleanest to worst.
  • msno.heatmap(df) - the nullity correlation: which columns tend to go missing together. A value near +1 means "when this one is missing, that one usually is too."
Real world

Run these on the LinkedIn postings frame and the ~70% missing salary is visible instantly - the bar for min_salary and max_salary is a stub next to the full-height title bar. The heatmap then shows min_salary and max_salary go missing together at nearly +1: when a poster omits pay, they omit all of it. That single fact shapes every salary decision you make today.

Self-studyQuantify what you saw2 min read

Pictures point you at the problem; a number lets you decide. Pair the plots with one line:

★ Missing % per column, worst firstjobs.isna().mean().sort_values(ascending=False).head(10) # multiply by 100 in your head - .70 means 70% missing
Look, then decide - never the reverse The most common beginner mistake is reaching for fillna or dropna before seeing the pattern. A column that's 2% missing at random and a column that's 70% missing structurally need opposite treatments. The plot tells you which one you're holding.
Part 2 · covers isna/notna, dropna, fillna

Handling missing values 5 min live

There is no default answer to a missing value. There's a decision, and it depends on how much is missing and why. Here's the framework analysts actually use.

A little missing (< ~5%) A lot missing (> ~50%) how much is gone -> Drop the rows dropna(subset=[col]) - a few gaps, random, safe to lose Drop the column mostly empty and not central to the question - let it go Impute (carefully) fillna(median) for skewed numbers, mode for categories - and note it Leave it + flag it analyze the subset that has it, say so out loud - the salary case Missing at random? How much? Those two questions pick the box - never blanket-fill.
🔍 Click to zoom - the drop-vs-impute-vs-flag decision, driven by how much is missing and why
LiveThe four tools and when to reach for each3 min
  • isna() / notna() - boolean masks. df["col"].isna().sum() counts gaps; df[df["col"].notna()] keeps only the rows that have a value (the "salaried subset" move).
  • dropna() - remove gaps. how="any" vs how="all", thresh=N to require N non-nulls, and subset=[...] to only judge specific columns. Dropping on the whole frame is usually too aggressive - scope it with subset.
  • fillna() - fill gaps with a value: a median, a mode, or a sentinel like "Unknown". Powerful and dangerous.
  • Mean vs median - salary is right-skewed (a few huge numbers pull the mean up). For skewed data, median is the honest center. Reach for mean only on roughly symmetric data.
Never blanket-fill without thinking df.fillna(0) across a whole frame is a classic disaster - it silently turns "we don't know this salary" into "this job pays $0," and every average you compute afterward is wrong. Fill a column at a time, with a reason.
Self-studyOur salary strategy, decided out loud2 min read

Applying the framework to the LinkedIn salary columns:

Real world

Salary is ~70% missing and it's the most interesting variable in the dataset - so it lands in the bottom-right box. We do not impute it: filling 70% of rows with one median would fabricate the majority of the data and bias every finding toward that single number. Instead we keep every row, build a clean salary_year where it exists, and analyze the ~30% salaried subset for pay questions - while saying clearly in the report that pay figures describe postings that disclosed salary. Honest, and still useful.

Part 3 · covers dtype coercion, .str methods, duplicates

Fixing types, text, and dupes 4 min live

With missingness handled, three mechanical clean-ups remain: get the dtypes right, tidy the free-text fields, and drop duplicate postings. These are the moves that turn a frame from "loads without error" into "safe to compute on."

LiveCoerce the dtypes1.5 min

A number stored as text won't add up; a date stored as text won't sort. Three workhorses fix this:

  • astype() - direct conversion when you know the data is clean: df["views"].astype("int64").
  • pd.to_numeric(s, errors="coerce") - convert to number, and turn anything unparseable into NaN instead of crashing. Essential for messy salary text.
  • pd.to_datetime(s) - parse the posted column into real timestamps so you can sort and resample later.
errors="coerce" is your seatbelt Without it, one stray value like "competitive" in a salary column throws and stops your whole notebook. With it, that value becomes NaN, the conversion finishes, and you decide what to do with the gaps deliberately.
LiveTame the free-text with .str1.5 min

Job titles are gloriously inconsistent: "Data Analyst", " data analyst ", "DATA ANALYST II". The .str accessor cleans a whole column at once:

  • .str.strip() - kill leading/trailing whitespace.
  • .str.lower() - normalize case so grouping works.
  • .str.contains("analyst") - boolean filter for title bucketing.
  • .str.replace(...) and .str.extract(r"...") - swap noise out, pull structured bits (like a level number) out with a regex.
LiveParse salary to a yearly number1 min

The core project transform. Salary lives across min_salary, max_salary, and pay_period (HOURLY / MONTHLY / YEARLY). We coerce the numbers, take the midpoint, then scale by period to a single annual figure:

★ The salary-to-yearly logic# coerce both to numeric (bad values -> NaN) lo = pd.to_numeric(jobs["min_salary"], errors="coerce") hi = pd.to_numeric(jobs["max_salary"], errors="coerce") mid = (lo + hi) / 2 # midpoint of the posted band # normalize to yearly: hourly * ~2080 work-hours, monthly * 12 factor = jobs["pay_period"].map({"HOURLY": 2080, "MONTHLY": 12, "YEARLY": 1}) jobs["salary_year"] = mid * factor

Rows with no salary stay NaN - exactly what we want. We're building the column, not filling the gaps.

Self-studyFind and drop duplicates2 min read

Scrapes double-post. df.duplicated().sum() counts exact duplicate rows; df.duplicated(subset=["job_id"]).sum() counts repeats of a business key. df.drop_duplicates(subset=["job_id"]) keeps the first of each. Always check the shape before and after so you know how many you removed - a silent 10% drop is worth noticing.

Demo 1 of 3

See the mess with missingno ★ 8 min · everyone profiles

Reload the postings frame from Session 1 into jobs. We profile before we touch anything.

Run all three missingno plots and read them out loud together.

★ The three-view missingness checkimport missingno as msno import matplotlib.pyplot as plt msno.matrix(jobs) # row-by-row: where do gaps fall? plt.show() msno.bar(jobs) # count per column: rank cleanest to worst plt.show() msno.heatmap(jobs) # nullity correlation: what goes missing together? plt.show()

Write down the three worst columns by missing %. Confirm with jobs.isna().mean().sort_values(ascending=False).head(5).

Real world

Your three worst columns will almost certainly be the salary trio. The heatmap makes the story concrete: min_salary and max_salary light up together near +1. You now know that "handle missing salary" is one decision, not two - and Demo 2 acts on it.

Demo 2 of 3

Build the clean salary column ★ 9 min · everyone builds

Coerce both salary bounds to numeric so stray text becomes NaN instead of crashing.

Combine min and max into a midpoint, then normalize the pay period to an annual figure.

★ min/max/period -> one yearly numberlo = pd.to_numeric(jobs["min_salary"], errors="coerce") hi = pd.to_numeric(jobs["max_salary"], errors="coerce") # if only one bound exists, midpoint still works via NaN-aware mean mid = pd.concat([lo, hi], axis=1).mean(axis=1) factor = jobs["pay_period"].map({"HOURLY": 2080, "MONTHLY": 12, "YEARLY": 1}) jobs["salary_year"] = mid * factor jobs["salary_year"].describe() # sanity-check the range - any $2M outliers?

Decide the missing strategy out loud: keep every row, do not impute the ~70% gap, plan to analyze the salaried subset with jobs[jobs["salary_year"].notna()] in Session 5.

Sanity-check every derived column After any transform, run .describe(). If the max salary reads $5,000,000 you probably have an hourly rate that wasn't scaled, or a typo row. Catch it now, before it becomes a headline.
Demo 3 of 3

Tidy titles, dedupe, save clean ★ 5 min · follow along

Normalize the title text so grouping works next week.

★ Clean text, dedupe, confirm the drop# tidy titles jobs["title"] = jobs["title"].str.strip().str.lower() # drop duplicate postings on the business key before = jobs.shape[0] jobs = jobs.drop_duplicates(subset=["job_id"]) print("removed", before - jobs.shape[0], "duplicate postings")

Save to a new file - never overwrite the raw download.

★ Save the cleaned frame (raw stays untouched)jobs.to_csv("postings_clean.csv", index=False) # Session 5 loads THIS file, not the raw one
Raw is sacred Your cleaned file is a rebuildable artifact - if you make a mistake you re-run the notebook. The raw postings.csv is your one source of truth; keep it read-only in your head. Every save this course does uses a new name.
After the session

This week ◐ 40 min total

Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · Why run missingno before you start dropping or filling?

A 2%-random gap and a 70%-structural gap need opposite treatments. The plots tell you which one you're holding - profile before you fix.

2 · What does pd.to_numeric(s, errors="coerce") do?

It's your seatbelt: one stray "competitive" in a salary column becomes NaN and the conversion finishes, instead of throwing and stopping the notebook.

3 · Why NOT impute the ~70% missing salary with the mean?

Filling 70% of rows with one number invents the majority of your data. Keep the rows, analyze the ~30% that disclosed pay, and state that clearly in the report.

Source material

Official sources covered

This session teaches the working content of the pandas missing-data and text guides, the free Kaggle Pandas micro-course, Wes McKinney's open-access Python for Data Analysis (3e), and the missingno and ydata-profiling docs. Certificates and graded exercises stay on those platforms - links provided.

Kaggle Pandas L5 - Data Types & Missing Valuesdtype, isna, fillna, dropna - Parts 2-3 + Demos
pandas guides - Missing data + Working with textNaN semantics, .str accessor, coercion - Parts 2-3
McKinney ch7 - Data Cleaning and Preparationmissing values, transforms, string ops - whole session
missingno docs - matrix / bar / heatmapseeing missingness before fixing - Part 1 + Demo 1
pandas Categorical dtypeintroduced in passing; memory + grouping payoff in Session 5

Session 4 cheat sheet · pin this

See it firstmsno.matrix(df) for row gaps · msno.bar(df) to rank columns · msno.heatmap(df) for what goes missing together.
Quantifydf.isna().mean().sort_values(ascending=False) - % missing per column, worst first.
Drop vs filldropna(subset=[...], thresh=N) to remove · fillna(median/mode/sentinel) to fill · never blanket-fill a whole frame.
Coerce typespd.to_numeric(s, errors="coerce") · pd.to_datetime(s) · astype() when clean. errors="coerce" turns junk into NaN.
Clean texts.str.strip().str.lower() · .str.contains(...) · .str.replace(...) · .str.extract(r"...") for structured bits.
Dedupe + savedf.drop_duplicates(subset=["job_id"]); then df.to_csv("postings_clean.csv", index=False) - a NEW file, raw untouched.