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

Explore and aggregate

Now the questions get answered. You'll learn the split-apply-combine engine behind groupby, reshape results into readable grids with pivot_table, and merge the dataset's separate tables into one picture. From your cleaned frame you pull real findings: median pay by role, the most-demanded skills, and how remote work moves the number.

🟠 Getting real Builders groupby + joins 45 min live + self-study
0-3 · Welcome 3-18 · Concepts 18-40 · Answer questions 40-45 · Q&A
Part 0

Where analysis starts answering questions

Sessions 1 to 4 got you a clean, trustworthy frame. That was setup. This is the payoff. Almost every real question you'll ever ask of data comes down to two operations: split the data into groups and compute something per group, and join separate tables into one. That's it. Median salary by experience level? Group. Top skills across postings? Group and count. Do bigger companies pay more? Join, then group. Master these and you can interrogate any dataset - today you interrogate the job market.

Live - presented in session Self-study - read after class ★ Build-along demo The project: the data job market
★ What you walk out with today Real findings, not exercises: a table of median salary_year by role and experience level, a ranked list of the most-demanded skills across all postings, and a pivot grid of pay by experience level crossed with remote status. Three answers you could put on a slide - and the summary frames you'll chart in Session 6.
Part 1 · covers groupby split-apply-combine

groupby: split-apply-combine 5 min live

The single most useful mental model in data analysis. When you write df.groupby("col")["val"].mean(), pandas does three things - and once you see them, groupby stops being magic.

1 · Split 2 · Apply 3 · Combine jr · 90k sr · 150k jr · 100k sr · 160k jr · 95k group: junior 90k · 100k · 95k group: senior 150k · 160k junior -> 95k senior -> 155k median per group .groupby("level")["salary"].median() One line, three moves: split rows by a key, apply a function to each group, combine into a summary.
🔍 Click to zoom - split-apply-combine: the engine under every groupby you'll ever write
Livegroupby, agg, and sorting the answer3 min

The basic form is df.groupby("key")["value"].func(). Where it gets powerful:

  • Multiple functions at once with .agg(): df.groupby("level")["salary_year"].agg(["median", "count"]) gives you the typical pay and how many postings back that number - always report both.
  • Group by several keys: groupby(["level", "remote"]) nests the split.
  • Sort the result with .sort_values("median", ascending=False) or grab the extremes with .nlargest(10). A groupby result is just another frame - you keep working on it.
Real world

On the LinkedIn data, jobs.groupby("experience_level")["salary_year"].median() answers a question people actually care about in one line: does the pay ladder climb the way you'd expect from entry to director? The count alongside it keeps you honest - a "median" backed by 12 postings is a rumor, not a finding.

Self-studyWhy count belongs next to every average2 min read

An aggregate hides its sample size. A group of 5,000 postings and a group of 8 both collapse to a single median, and on a chart they look equally solid. Always carry the count: .agg(["median", "count"]). In your Session 8 report, a footnote of group sizes is the difference between "credible" and "made up." Small groups are where confident wrong conclusions live.

Part 2 · covers pivot_table, crosstab, melt

Pivot tables and reshaping 4 min live

groupby gives you a list of answers; a pivot table lays them out as a readable 2D grid. Same computation, better shape for the eye - and the reverse move, melt, turns wide back to long when a tool needs it.

Wide - a pivot grid, easy to read remote onsite junior 98k 92k senior 165k 150k Long - tidy rows, easy for tools junior · remote · 98k junior · onsite · 92k senior · remote · 165k senior · onsite · 150k one row per combination melt -> <- pivot Same numbers, two shapes: pivot_table for humans to read, melt for tools to consume.
🔍 Click to zoom - wide vs long: pivot reshapes long rows into a grid, melt unpivots a grid back to tidy rows
Livepivot_table, crosstab, and when to use which3 min
  • pivot_table(index=, columns=, values=, aggfunc=) - the workhorse. index becomes the rows, columns the columns, and each cell is aggfunc applied to values for that combination. It's groupby-on-two-keys, laid out as a grid.
  • pd.crosstab(a, b) - a shortcut for counting combinations (a frequency table). Reach for it when you just want "how many postings are remote × senior."
  • melt vs pivot - melt goes wide -> long (many columns collapse into key/value rows), pivot goes long -> wide. Charting libraries usually want long; humans usually want wide.
pivot_table beats pivot on real data Plain pivot errors if any index/column pair repeats. pivot_table takes an aggfunc so duplicates get summarized instead of crashing - on messy real data, reach for pivot_table by default.
Part 3 · covers merge, join, concat

Joining the tables 5 min live

The dataset isn't one table - it's several that share key columns: postings, companies, skills, salaries. The full picture only appears when you stitch them together on those keys. That's what merge does.

postings (keep all) company_id · title C1· data analyst C2· ml engineer C1· data eng companies (lookup) company_id · size C1· large C2· startup merged on company_id title · size data analyst · large ml engineer · startup data eng · large C1 matched twice (one-to-many) how="left" keeps every posting; one company matches many postings - a one-to-many join.
🔍 Click to zoom - a left join on company_id: every posting kept, company size looked up and attached
LiveThe four join types and the keys3 min

pd.merge(left, right, on="key", how=...) combines two frames on a shared column. The how decides which rows survive:

  • inner - only rows with a match on both sides. The safe default when you need complete pairs.
  • left - keep all left rows, attach right where it matches, NaN where it doesn't. This is the everyday analyst join: keep every posting, enrich it.
  • right / outer - keep all right rows / keep everything from both.
  • keys - on="company_id" when both sides name it the same; left_on=/right_on= when they differ. Watch one-to-many: one company matched to many postings multiplies rows - expected here, but always check the shape after.

pd.concat([a, b]) is different - it stacks frames with the same columns (more rows) rather than matching on a key. Use it to append, not to enrich.

Real world

Join company_size onto postings with a left join on company_id, then group by size and take the median salary_year - and you've answered "do bigger companies pay more?" with your own data. The left join guarantees you don't quietly drop postings whose company is missing from the lookup table; they just get a NaN size you can filter later.

Demo 1 of 3

Highest-paying roles by group ★ 8 min · everyone groups

Load your cleaned frame from Session 4 and keep only rows that disclosed pay.

Group by experience level and a title bucket, take median pay and count, then sort.

★ Median pay + count per group, rankedjobs = pd.read_csv("postings_clean.csv") paid = jobs[jobs["salary_year"].notna()] # the salaried subset # a rough title bucket from the free text paid = paid.assign( role=paid["title"].str.extract(r"(analyst|engineer|scientist|manager)", expand=False) ) summary = (paid.groupby(["experience_level", "role"])["salary_year"] .agg(["median", "count"]) .sort_values("median", ascending=False)) summary.head(15)
Read the count column first Before you get excited about a $220k median, glance at its count. A top row backed by 9 postings is noise; one backed by 3,000 is a finding. Filter out tiny groups (summary[summary["count"] >= 50]) before you quote a number.
Demo 2 of 3

The most-demanded skills ★ 7 min · everyone merges

The skills live in their own table keyed to job_id. Merge it onto postings, then count.

★ Merge skills, then rank demandskills = pd.read_csv("job_skills.csv") # one skill per row, keyed by job_id # attach postings info to each skill row (left keeps every skill listing) merged = pd.merge(skills, jobs[["job_id", "title"]], on="job_id", how="left") # if skills are one-per-row, a plain value_counts IS the ranking top15 = merged["skill_name"].value_counts().head(15) top15

If a posting stores several skills in one cell instead, split and explode first: df.assign(skill=df["skills"].str.split(",")).explode("skill"), then value_counts.

Real world

This is the finding people screenshot: the top 15 skills across 124k postings, ranked. SQL and Python usually fight for the top; the surprises further down (a cloud platform, a BI tool) are what make the post worth reading. You built it with one merge and one value_counts.

Demo 3 of 3

Pay pivot: experience × remote ★ 6 min · follow along

Lay median pay out as a grid so the story reads at a glance.

★ pivot_table of median pay, then save itgrid = paid.pivot_table( index="experience_level", columns="remote_allowed", values="salary_year", aggfunc="median", ) grid # rows = experience, columns = remote vs onsite, cells = median pay

Read the story out of the grid: does remote pay more at every level, or only for senior roles? Note what you see.

Save the summary frames - Session 6 turns these exact tables into charts.

★ Persist the summaries for chartingsummary.to_csv("summary_pay_by_role.csv") top15.to_csv("summary_top_skills.csv") grid.to_csv("summary_pay_pivot.csv")
A summary frame is a deliverable Each of these small tables answers one project question and feeds one Session 6 chart. Saving them means your exploration and your visuals never drift out of sync - the chart plots exactly the number you reported.
After the session

This week ◐ 40 min total

Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · What does "split-apply-combine" actually mean?

That's the engine under every groupby: split by the key, apply the aggregate per group, combine the per-group answers into one summary frame.

2 · Which merge keeps every row from the left table and matches the right where it can?

how="left" keeps all left rows and fills NaN where the right side has no match - the everyday "keep every posting, enrich it" join.

3 · How does pivot_table differ from a plain groupby?

Same computation, different shape: pivot_table spreads two grouping keys across rows and columns for the eye; groupby hands back a result indexed by the group.

Source material

Official sources covered

This session teaches the working content of the pandas groupby, merge/join/concat, and pivot guides, the free Kaggle Pandas micro-course, and Wes McKinney's open-access Python for Data Analysis (3e). Certificates and graded exercises stay on those platforms - links provided.

Kaggle Pandas L4 & L6 - Grouping & Sorting, Renaming & Combininggroupby, agg, sort, merge - Parts 1 & 3 + Demos
pandas guides - groupby + merge/join/concat + pivotsplit-apply-combine, join types, reshaping - all parts
McKinney ch10 - Data Aggregation & Group Operationsgroupby mechanics, agg, pivot_table - Parts 1-2
McKinney ch8 - Data Wrangling: Join, Combine, Reshapemerge, concat, melt vs pivot - Parts 2-3
pandas hierarchical index (MultiIndex)appears from multi-key groupby; worked at a practical level, not exhaustively

Session 5 cheat sheet · pin this

groupby + aggdf.groupby("key")["val"].agg(["median", "count"]) - always carry the count next to the average.
Sort / top-Nresult.sort_values("median", ascending=False) · or .nlargest(10) to grab the extremes.
pivot_tabledf.pivot_table(index=, columns=, values=, aggfunc="median") - two grouping keys laid out as a readable grid.
mergepd.merge(left, right, on="company_id", how="left") - inner/left/right/outer picks which rows survive.
concatpd.concat([a, b]) - stack frames with the same columns (more rows). Append, don't enrich.
melt vs pivotmelt = wide -> long (tidy rows for tools) · pivot = long -> wide (grid for humans).