Project: an exploratory analysis report
Five business questions answered with cleaning, joins, charts and a test, then written up.
Create a free account Try part of it here
Why it matters
"Can you take a look at the numbers?" is one of the most common requests a data person gets, and one of the easiest to answer badly: a pile of charts with no conclusion. This project practices the whole job, from questions to a short report. It pulls together everything from the data-analysis track. It's also exactly the exploration you'd do before building any model on a new dataset.
What you'll be able to do
- Turn a vague request into specific questions, each with the table and method that answers it
- Clean, join and summarize several tables into one analysis table you can trust
- Answer each question with a number, a chart or a test, and say how sure you are
- Write findings that lead with the answer, give the size and name the caveats
What it covers
- The brief
- One table to answer everything
- Questions 1 and 2: growth and product lines
- Question 3: a test, and a null result
- Questions 4 and 5: seasons and stores
- Writing it up
- Worked example: the report's figure
The brief
The shop's leadership is planning next year and asks for "a review of 2024 and 2025". That's too vague to answer, so the first job is to turn it into questions that each have a clear answer:
- Growth. How much did revenue grow from 2024 to 2025, overall and in each store?
- Product lines. Which line earns the most profit, and which has the best margin?
- Members. Do loyalty members spend more per order than other customers?
- Seasons. When does each category sell best?
- Stores. Which store earns the most per visit?
Write questions like these down before you look at the data. Each names a measure (revenue, margin, order value) and a comparison (years, lines, members). With the questions written down, you can tell when you're finished. Without them, exploring has no end.
You'll need four tables: orders, the catalog (costs, categories, line IDs), product lines (names) and stores (visits). Start by building one analysis table with everything on it.
One table to answer everything
Clean first, then join, then add the columns every question needs. Do it once, in a function, so every answer starts from the same data:
import pandas as pd def dataset(name): return pd.read_csv(f"https://api.gradient.test/v1/datasets/{name}.csv") raw, catalog, lines = dataset("orders"), dataset("catalog"), dataset("product-lines") def build(raw, catalog, lines): orders = raw.drop_duplicates() orders = orders[orders["sku"] != "TEST-000"].copy() full = orders.merge(catalog[["sku", "category", "cost", "line_id"]], on="sku", how="left", validate="many_to_one") full = full.merge(lines.rename(columns={"name": "line"})[["line_id", "line"]], on="line_id", how="left", validate="many_to_one") full["revenue"] = full["qty"] * full["unit_price"] full["profit"] = full["revenue"] - full["qty"] * full["cost"] full["year"] = full["date"].str[:4].astype(int) full["month"] = full["date"].str[:7] return full full = build(raw, catalog, lines) print(len(raw), "raw lines ->", len(full), "clean lines") print(full["date"].min(), "to", full["date"].max()) print(full[["category", "line"]].isna().sum().to_dict())
Three checks follow the build. The row count shows what cleaning removed. The date range shows the data covers both years in full, so comparing 2024 with 2025 is fair. The missing-value count shows every line found its category and product line. Skipping these checks is how an analysis goes wrong quietly.
Questions 1 and 2: growth and product lines
import pandas as pd def dataset(name): return pd.read_csv(f"https://api.gradient.test/v1/datasets/{name}.csv") orders = dataset("orders").drop_duplicates() orders = orders[orders["sku"] != "TEST-000"].copy() full = orders.merge(dataset("catalog")[["sku", "cost", "line_id"]], on="sku").merge(dataset("product-lines").rename(columns={"name": "line"})[["line_id", "line"]], on="line_id") full["revenue"] = full["qty"] * full["unit_price"] full["profit"] = full["revenue"] - full["qty"] * full["cost"] full["year"] = full["date"].str[:4].astype(int) by_year = full.groupby("year")["revenue"].sum() print("overall growth:", round((by_year[2025] / by_year[2024] - 1) * 100, 1), "%") stores = full.pivot_table(index="store", columns="year", values="revenue", aggfunc="sum") stores["growth_pct"] = ((stores[2025] / stores[2024] - 1) * 100).round(1) print(stores.sort_values("growth_pct", ascending=False)) lines = full.groupby("line").agg(revenue=("revenue", "sum"), profit=("profit", "sum")) lines["margin_pct"] = (lines["profit"] / lines["revenue"] * 100).round(1) print(lines.sort_values("profit", ascending=False).round(1))
Revenue grew 8.4%, but not everywhere. The web shop and Portland grew; Denver slipped a little; Seattle fell by 16%. Before you report that as a crisis, remember that Seattle is a small store, with about 200 orders a year. A small total swings more from year to year by chance, just as small groups did in the grouping lesson. Report it as something to watch, and check it against more data, such as visits or the first months of next year.
Ridgeline (tents) earns by far the most profit, because tents are expensive. Ember (camp kitchen) has the best margin. Which one "matters most" depends on whether leadership wants more profit dollars or more profit per dollar sold, so report both.
Question 3: a test, and a null result
Members get discounts and points. Do they spend more per order? Collapse lines to orders first, as the grouping lesson showed, then compare the two groups of order totals with Welch's t-test:
import pandas as pd from scipy import stats orders = pd.read_csv("https://api.gradient.test/v1/datasets/orders.csv").drop_duplicates() orders = orders[orders["sku"] != "TEST-000"].copy() orders["revenue"] = orders["qty"] * orders["unit_price"] per_order = orders.groupby("order_id").agg(member=("member", "first"), total=("revenue", "sum")) members = per_order[per_order["member"] == 1]["total"] others = per_order[per_order["member"] == 0]["total"] result = stats.ttest_ind(members, others, equal_var=False) low, high = result.confidence_interval(0.95) print(len(members), "member orders,", len(others), "others") print("mean order:", round(members.mean(), 2), "vs", round(others.mean(), 2)) print("gap:", round(members.mean() - others.mean(), 2), " p =", round(result.pvalue, 3)) print("95% interval for the gap:", round(low, 2), "to", round(high, 2))
Members' orders average about $8 less, but p is 0.27, and the interval runs from about −$23 to +$6. So the data shows no clear difference either way. That's a real finding. Report it honestly as "no evidence that members spend more per order". It is not "members spend less", and it is not "proven equal". A null result is still worth reporting: it stops the team from assuming the loyalty program raises order size.
Also note what this comparison can't show. Members chose to join, so they may differ from other customers in many ways. Even a clear gap wouldn't prove the program caused it.
import pandas as pd lines = pd.DataFrame({"order_id": [1, 1, 1, 2], "member": [1, 1, 1, 0], "revenue": [10, 10, 10, 50]}) print(lines.groupby("member")["revenue"].mean().to_dict(), lines.groupby("order_id")["revenue"].sum().to_dict())
Show the answer
{0: 50.0, 1: 10.0} {1: 30, 2: 50}
Averaged over lines, the member's three $10 lines give $10. But the member's one order was worth $30. Which number you report depends on whether the question is about lines or orders.
A good finding gives the answer, its size and how sure you are, without claiming more than the data shows.
Questions 4 and 5: seasons and stores
Seasons need month-of-year, so every January lands in the same row, whichever year it's from:
full["month_of_year"] = full["date"].str[5:7].astype(int) season = full.pivot_table(index="month_of_year", columns="category", values="qty", aggfunc="sum") print(season.idxmax())
Apparel peaks in winter, around January, while tents, packs and footwear peak from June to August. That's useful for stock planning, and a good reason to look at categories separately: added together, the opposite seasons partly cancel out and hide both patterns.
Per-visit revenue repeats the joins lesson: total revenue per store, joined to the stores table, divided by two years of visits. Portland earns most per visit and the web shop least. That's the expected pattern for browsing online, and a useful comparison to the growth numbers. The web shop is growing fastest, but each visit is worth the least.
Writing it up
A report is for someone who won't read your code. Put the answers first:
- Lead with findings, one per question, each with its number: "Revenue grew 8.4%."
- Give sizes and uncertainty, not just directions: intervals for tests, and small samples called out.
- One chart per finding, titled with the finding itself ("Apparel peaks in winter"), not the data ("Units by month").
- Caveats in plain words: what was removed in cleaning, what the data can't show (cause and effect), and what you'd check next.
- Keep the code reproducible, so next year's review is a rerun, not a rewrite.
Leave out the dead ends. You may have made thirty charts to find five findings, but the reader needs the five. Keep the rest in your notebook, in case someone asks.
Worked example: the report's figure
Two panels that carry the two biggest findings: revenue month by month across both years, and margin by product line.
import matplotlib.pyplot as plt import pandas as pd def dataset(name): return pd.read_csv(f"https://api.gradient.test/v1/datasets/{name}.csv") orders = dataset("orders").drop_duplicates() orders = orders[orders["sku"] != "TEST-000"].copy() full = orders.merge(dataset("catalog")[["sku", "cost", "line_id"]], on="sku").merge(dataset("product-lines").rename(columns={"name": "line"})[["line_id", "line"]], on="line_id") full["revenue"] = full["qty"] * full["unit_price"] full["profit"] = full["revenue"] - full["qty"] * full["cost"] full["month"] = full["date"].str[:7] monthly = full.groupby("month")["revenue"].sum() lines = full.groupby("line").agg(revenue=("revenue", "sum"), profit=("profit", "sum")) margin = (lines["profit"] / lines["revenue"] * 100).sort_values() fig, (left, right) = plt.subplots(1, 2, figsize=(12, 3.8)) left.plot(range(len(monthly)), monthly.values, marker=".") left.set_xticks(range(0, 24, 3), monthly.index[::3], rotation=45) left.set_ylim(bottom=0) left.set_title("Revenue grew 8.4% in 2025, peaking in October") left.set_ylabel("revenue ($)") right.barh(margin.index, margin.values) right.set_xlim(left=0) right.set_title("Ember has the best margin; Scree the worst") right.set_xlabel("profit margin (%)") fig.tight_layout() print(monthly.idxmax(), round(margin.max(), 1), round(margin.min(), 1))
Plotting against range(len(monthly)) and labeling every third month keeps 24 month labels from crowding the axis. Both charts start at zero, because the second one is a bar chart and the first compares sizes across time. The titles state findings, so the figure works even if someone only glances at it. Sorting the margins puts the bars in order, which makes "best" and "worst" obvious at a glance. This figure, the five findings and a caveats paragraph make a complete one-page report.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
A test gives p = 0.27 for the gap between members' and others' order sizes. What should the report say?