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.