Not a tour of the language — only what an analyst actually uses, and each idea explained by why it exists, not just how it is typed.
A type tells you what you may do with a value. Numbers add up, strings glue together. Mix them up and "120" + "80" silently becomes "12080" instead of 200 — a bug that raises no error and simply puts a wrong number in your report.
None deserves special care: it means "there is no value", which is not zero. A user who never bought has revenue None; a user who bought and refunded everything has 0. Treating them the same shifts your average.
# A type is a promise about what you can do with a value users = 1200 # int — whole number conv = 0.048 # float — fractional metric = "checkout_rate" # str — text is_sig = True # bool — yes/no missing = None # None — there is NO value (not zero!) print(type(users), type(conv)) # The classic trap: numbers that are secretly strings a, b = "120", "80" print(a + b) # 12080 — glued, not added print(int(a) + int(b)) # 200 — as intended # None is not zero never_bought = None refunded_all = 0 print(never_bought is None, refunded_all == 0)
The structure you choose decides both readability and speed. The rule: list when order matters, dict when you look values up by key, set when you need uniqueness or a fast "is it there?" check.
The speed gap is not academic. A list checks elements one by one; a set jumps straight to the answer through a hash. Inside a loop over a million rows that is the difference between seconds and hours.
# List — order matters, access by position revenue = [120.5, 80.0, 340.2, 55.0] print(revenue[0], revenue[-1], revenue[1:3]) # Dict — lookup by key user_plan = {101: "pro", 102: "free", 103: "pro"} print(user_plan[102]) # free print(user_plan.get(999, "unknown")) # safe default, no error # Set — uniqueness and fast membership tests paid_users = {101, 103, 107} print(107 in paid_users) # Why the choice matters for speed import time big_list = list(range(1_000_000)) big_set = set(big_list) t0 = time.perf_counter(); 999_999 in big_list; t_list = time.perf_counter() - t0 t0 = time.perf_counter(); 999_999 in big_set; t_set = time.perf_counter() - t0 print(f"list: {t_list*1000:.2f} ms | set: {t_set*1000:.4f} ms")
A comprehension builds a collection in one line. The gain is not brevity but intent: it says "this is a transformation of that", instead of "make an empty list, then append over and over".
orders = [
{"id": 1, "amount": 120, "status": "paid"},
{"id": 2, "amount": 0, "status": "cancelled"},
{"id": 3, "amount": 340, "status": "paid"},
]
amounts = [o["amount"] for o in orders] # transform
paid = [o["amount"] for o in orders if o["status"] == "paid"] # + filter
by_id = {o["id"]: o["amount"] for o in orders} # dict
statuses = {o["status"] for o in orders} # set
print(amounts)
print(paid)
print(by_id)
print(sorted(statuses))A function is how you stop copy-pasting. The real benefit is not elegance: when a formula changes you fix it once instead of hunting every copy — and a copy you miss is exactly how two slides end up disagreeing.
def lift(control, variant): """Relative uplift in %. The docstring is what your editor shows as a hint later — it is not decoration.""" if control == 0: return None # honestly return "unknown" return (variant - control) / control * 100 print(round(lift(0.098, 0.1136), 1)) print(lift(0, 0.05)) # None — no division by zero def summarize(values, digits=2, with_median=True): out = {"n": len(values), "mean": round(sum(values)/len(values), digits)} if with_median: srt = sorted(values); mid = len(srt)//2 out["median"] = round(srt[mid] if len(srt) % 2 else (srt[mid-1]+srt[mid])/2, digits) return out print(summarize([120, 80, 340, 55]))
These are the classic interview problems — but what matters here is the pattern behind each one. The same four patterns underpin how pandas performs a join, a group-by or a rolling window.
Complexity answers one question: as data grows, how fast does the work grow? O(n) means one pass — double the rows, double the time. O(n²) means comparing every element with every other — double the rows and the time goes up fourfold. Most "why is my script hanging" stories are an accidental O(n²).
The practical takeaway: a loop inside a loop over the same data is a red flag. Almost always it can be replaced by a dictionary lookup, turning O(n²) into O(n).
import time def has_duplicate_slow(items): # O(n^2) — every pair compared for i in range(len(items)): for j in range(i+1, len(items)): if items[i] == items[j]: return True return False def has_duplicate_fast(items): # O(n) — one pass, remembering seen = set() for x in items: if x in seen: return True seen.add(x) return False data = list(range(5000)) # worst case: no duplicates at all t0 = time.perf_counter(); has_duplicate_slow(data); slow = time.perf_counter()-t0 t0 = time.perf_counter(); has_duplicate_fast(data); fast = time.perf_counter()-t0 print(f"O(n^2): {slow*1000:.1f} ms") print(f"O(n): {fast*1000:.2f} ms") print(f"faster: {slow/fast:.0f}x")
The single most useful pattern in analytics. Instead of comparing every pair, you walk once and remember what you have already seen. Whenever a task sounds like "find a pair / find a duplicate / match one table to another", this is the shape of the answer.
# Pattern: a dict remembers what we have already seen. # Analytical meaning: find two orders that add up to a target sum. def two_sum(nums, target): seen = {} # value -> index for i, n in enumerate(nums): if target - n in seen: # has the complement appeared before? return [seen[target - n], i] seen[n] = i return [] print(two_sum([120, 45, 300, 80, 55], 135)) # 80 + 55 # Same pattern: match two tables by key without a nested loop users = {101: "RU", 102: "KZ", 103: "RU"} orders = [(101, 120), (103, 340), (999, 50)] joined = [(uid, amt, users.get(uid, "unknown")) for uid, amt in orders] print(joined)
merge in pandas is exactly this — it builds a hash of one table and walks the other once. Understanding the pattern is understanding why a join on an indexed key is fast.Counting occurrences is the most common analytical operation there is: popular pages, frequent errors, best-selling items. Counter does it in one pass and hands you the ranking ready-made.
from collections import Counter pages = ["/home","/pricing","/home","/docs","/home","/pricing","/blog"] counts = Counter(pages) print(counts) # how many times each page appeared print(counts.most_common(2)) # ranking, ready-made # Share of traffic, not just raw counts total = sum(counts.values()) for page, n in counts.most_common(3): print(f"{page:<10} {n} {n/total*100:.0f}%")
When a question involves "k consecutive days", recomputing the sum for every position is wasteful. A sliding window adds the entering value and subtracts the leaving one — one pass instead of many. This is precisely what rolling() does in pandas.
# Best 3 consecutive days of sales. # Naive: recompute the sum for every position — O(n*k) # Sliding window: add the entering value, subtract the leaving one — O(n) def max_window(values, k): current = sum(values[:k]) best = current for i in range(k, len(values)): current += values[i] - values[i - k] best = max(best, current) return best sales = [120, 80, 300, 55, 400, 90, 20] print(max_window(sales, 3)) # 300 + 55 + 400 # pandas does exactly this with rolling() import pandas as pd print(pd.Series(sales).rolling(3).sum().max())
Sometimes the key you need is not in the data — you compute it. Sorting the letters of a word groups anagrams; normalising a name groups duplicate customers; rounding a timestamp groups events into hours. The mechanism is identical, and it is what groupby does under the hood.
from collections import defaultdict # Pattern: group by a key you compute yourself. def group_by_key(items, key_fn): groups = defaultdict(list) for x in items: groups[key_fn(x)].append(x) return dict(groups) # Anagrams: the key is the sorted letters words = ["listen", "silent", "enlist", "google", "gogole"] print(group_by_key(words, lambda w: "".join(sorted(w)))) # Same mechanism, real task: merge duplicate customers names = ["Ivan Petrov", "ivan petrov", "Anna Sidorova"] normalise = lambda s: " ".join(s.lower().split()) print(group_by_key(names, normalise))
Where the language stops and the craft begins. Half of all analytical errors are born not in the calculation but in how the table was assembled.
numpy stores numbers in one continuous block of memory and applies an operation to the whole block at once, in compiled code. A Python loop, by contrast, unpacks and repacks every value individually. That is where the order-of-magnitude difference comes from — it is not a style preference.
import numpy as np, pandas as pd, time s = pd.Series(np.random.rand(100_000)) t0 = time.perf_counter(); loop = [v * 1.2 for v in s]; t_loop = time.perf_counter()-t0 t0 = time.perf_counter(); vec = s * 1.2; t_vec = time.perf_counter()-t0 print(f"loop: {t_loop*1000:.1f} ms") print(f"vector: {t_vec*1000:.1f} ms") print(f"faster: {t_loop/t_vec:.0f}x") # numpy also gives whole-array maths in one call a = np.array([120.0, 80.0, 340.0, 55.0]) print(a.mean(), a.std().round(2), np.percentile(a, 90))
for over the rows of a DataFrame, there is almost certainly a vector equivalent. The exact multiple depends on the machine, but the order of magnitude is stable.The most common mistake is leaving everything as strings. Then dates will not sort, categories waste memory, and numbers will not add up. Declare types as you build the table, not after something breaks.
Note the fixed seed. Anything random — sampling, simulation, synthetic data — must be reproducible, otherwise tomorrow you get different numbers and cannot tell whether the data changed or you simply got lucky.
import pandas as pd, numpy as np rng = np.random.default_rng(42) # fixed seed -> reproducible n = 500 users = pd.DataFrame({ "user_id": np.arange(1, n+1), # int "signup": pd.to_datetime("2026-01-01") # real dates + pd.to_timedelta(rng.integers(0, 180, n), unit="D"), "country": pd.Categorical(rng.choice(["RU","KZ","BY"], n, p=[.6,.25,.15])), "plan": pd.Categorical(rng.choice(["free","pro"], n, p=[.75,.25]), categories=["free","pro"], ordered=True), "revenue": np.round(rng.lognormal(2.5, 1.1, n), 2), }) users.loc[rng.choice(n, 25, replace=False), "revenue"] = np.nan # realistic gaps print(users.dtypes.to_string()) obj_kb = users["country"].astype(object).memory_usage(deep=True)/1024 cat_kb = users["country"].memory_usage(deep=True)/1024 print(f"\nobject: {obj_kb:.1f} KB | category: {cat_kb:.1f} KB " f"-> {obj_kb/cat_kb:.0f}x smaller")
category matters: on 500 rows the saving is invisible, on ten million it is the difference between "the table fits in memory" and "the kernel died". It also gives correct sorting: ordered=True knows that free comes before pro.This is the central idea of data organisation. Wide is comfortable for a human reading a report. Long (or "tidy") is the only shape that group-bys and plotting libraries work with naturally.
import pandas as pd wide = pd.DataFrame({ "user_id": [1, 2, 3], "jan": [100, 150, 90], "feb": [120, 140, 110], "mar": [130, 160, 95], }) print("WIDE — easy for a human to read:") print(wide.to_string(index=False)) # melt: wide -> long. One row = one observation long = wide.melt(id_vars="user_id", var_name="month", value_name="revenue") print("\nLONG — easy to analyse:") print(long.head(4).to_string(index=False)) # pivot: long -> wide again, for a report print("\nBack via pivot:") print(long.pivot(index="user_id", columns="month", values="revenue").to_string())
melt before analysis.pivot the columns came back alphabetically — feb, jan, mar. For months that is wrong. This is exactly why a month should be stored as a date or an ordered category, never as a plain string.Four operations cover most day-to-day work: look at the data, select what you need, group it, and join it to another table. Everything else is a variation.
import pandas as pd orders = pd.DataFrame({ "order_id": [1, 2, 3, 4, 5, 6], "user_id": [10, 10, 11, 12, 12, 13], "country": ["RU", "RU", "KZ", "KZ", "RU", "KZ"], "amount": [120.0, 0.0, 340.5, 55.0, 0.0, 780.0], "status": ["paid","cancelled","paid","paid","cancelled","paid"], }) print(orders.shape) # rows, columns print(orders.loc[0, "amount"]) # by label print(orders.iloc[0, 3]) # by position by_country = orders.query("status == 'paid'").groupby("country").agg( orders=("order_id", "count"), buyers=("user_id", "nunique"), # distinct, not rows revenue=("amount", "sum"), ).round(1) print(by_country) users = pd.DataFrame({"user_id": [10,11,12,13], "plan": ["free","pro","free","pro"]}) print(orders.merge(users, on="user_id", how="left").groupby("plan")["amount"].sum())
.loc addresses by label, .iloc by position. And count() counts rows while nunique() counts distinct values — confusing those two is the most common cause of inflated metrics.This is the step that separates an analyst from someone who can write code. A beautiful chart built on broken data is worse than no chart at all, because people will believe it.
import pandas as pd, numpy as np df = pd.DataFrame({ "user_id": [1, 2, 2, 3, 4, 5], "signup": ["2026-01-05","2026-01-07","2026-01-07","2026-02-01",None,"2026-02-14"], "revenue": [100.0, np.nan, np.nan, 250.0, 80.0, -15.0], "country": ["RU","ru "," KZ","KZ","RU","XX"], }) print("shape:", df.shape) print("\nmissing values:\n", df.isna().sum().to_string()) print("\nfull duplicate rows:", df.duplicated().sum()) df["signup"] = pd.to_datetime(df["signup"], errors="coerce") # dates as dates df["country"] = df["country"].str.strip().str.upper() # unify categories print("\ncountries after cleaning:", df["country"].unique().tolist()) print("negative revenue:", (df["revenue"] < 0).sum(), "rows") df = df.drop_duplicates(subset=["user_id"]) # duplicate users df = df[df["revenue"].fillna(0) >= 0] # drop impossible values df["revenue"] = df["revenue"].fillna(0) # NaN here means "did not pay" print("\nresult:", df.shape)
duplicated() found zero full duplicates even though user 2 appears twice — the rows differ in the revenue column. That is why duplicate users are caught with drop_duplicates(subset=[...]), not by comparing whole rows.value_counts(). Automation helps; it does not replace looking at your data.Analytical code is read more often than it is written, and decisions are made from its output. Badly written code is not merely ugly — it quietly produces wrong numbers.
Names instead of riddles. d, x, tmp2 mean nothing a week later. orders, paid_orders, arpu_by_country read like a sentence. A variable name is free documentation.
An explicit copy instead of a warning. The most common pandas trap: you filter a table, then write into the result, and pandas cannot tell whether you meant to change the copy or the original. The result is a warning and values that silently do not get written.
One readable chain instead of five variables. With t1…t5 it is easy to lose track of which one is current and accidentally carry a stale table into the final calculation.
import pandas as pd orders = pd.DataFrame({"country": ["RU","KZ","RU"], "amount": [1200, 300, 800], "status": ["paid","paid","cancelled"]}) # BAD: pandas cannot tell if you mean the copy or the original subset = orders[orders["country"] == "RU"] # subset["is_big"] = subset["amount"] > 1000 -> SettingWithCopyWarning # GOOD 1: say explicitly that this is a separate table subset = orders[orders["country"] == "RU"].copy() subset["is_big"] = subset["amount"] > 1000 # GOOD 2: modify the original through .loc, no intermediate table orders.loc[orders["country"] == "RU", "is_big"] = orders["amount"] > 1000 # One readable chain instead of five throwaway variables top = (orders .query("status == 'paid' and amount > 0") .groupby("country")["amount"] .sum() .sort_values(ascending=False) .head(5)) print(top.to_string())
Six tasks in the form they actually arrive: a question from a product manager, a trap in the naive answer, and the code that gets it right.
The thing to get right: count distinct users, not events. Otherwise one person who opened the form twice is counted twice and the funnel inflates.
import pandas as pd import matplotlib.pyplot as plt events = pd.DataFrame({ "user_id": [1,1,1,2,2,3,3,3,3,4,5,5,6,6,6,7,8,8,8,8], "step": ["visit","signup_form","submitted","visit","signup_form", "visit","signup_form","submitted","activated","visit", "visit","signup_form","visit","signup_form","submitted", "visit","visit","signup_form","submitted","activated"] }) order = ["visit","signup_form","submitted","activated"] funnel = events.groupby("step")["user_id"].nunique().reindex(order) conv = (funnel / funnel.iloc[0] * 100).round(1) print(pd.DataFrame({"users": funnel, "conv_%": conv})) fig, ax = plt.subplots(figsize=(7,3.2)) ax.barh(range(len(funnel))[::-1], funnel.values, color="#5B5FE9", height=.62) ax.set_yticks(range(len(funnel))[::-1]); ax.set_yticklabels(order) plt.show()
Revenue is almost always skewed: a handful of large customers drag the mean upwards. One number hides that; the distribution reveals it.
import numpy as np, pandas as pd, seaborn as sns import matplotlib.pyplot as plt rng = np.random.default_rng(7) revenue = pd.Series(np.round(rng.lognormal(2.6, 1.15, 2000), 2)) print("mean (ARPU):", round(revenue.mean(), 2)) print("median: ", round(revenue.median(), 2)) top10 = revenue.nlargest(200).sum() / revenue.sum() * 100 print("top 10% share of revenue:", round(top10, 1), "%") print("users below the mean:", round((revenue < revenue.mean()).mean()*100, 1), "%") fig, ax = plt.subplots(figsize=(7,3.2)) sns.histplot(revenue, bins=60, color="#5B5FE9", alpha=.55, ax=ax) ax.axvline(revenue.mean(), color="#E11D48", lw=2, label="mean") ax.axvline(revenue.median(), color="#059669", lw=2, ls="--", label="median") ax.set_xlim(0, revenue.quantile(.99)); ax.legend() plt.show()
The technique: pivot turns a long table into a cohort matrix, then each row is divided by its own month zero.
import pandas as pd, seaborn as sns import matplotlib.pyplot as plt df = pd.DataFrame({ "cohort": ["2026-01"]*4 + ["2026-02"]*3 + ["2026-03"]*2 + ["2026-04"], "month": [0,1,2,3, 0,1,2, 0,1, 0], "retained": [1000,420,302,217, 1100,356,256, 900,311, 1250], }) pivot = df.pivot(index="cohort", columns="month", values="retained") ret = pivot.div(pivot[0], axis=0) * 100 # % of each cohort's own month 0 print(ret.round(1).to_string()) fig, ax = plt.subplots(figsize=(7,3.0)) sns.heatmap(ret, annot=True, fmt=".0f", cmap="Purples", cbar=False, linewidths=3, linecolor="white", ax=ax) plt.show()
A point estimate alone means nothing — you need the confidence interval. If the intervals overlap, the difference may well be noise.
import numpy as np, pandas as pd from scipy import stats import matplotlib.pyplot as plt data = {"Control": (2500, 245), "Variant A": (2500, 284), "Variant B": (2500, 262)} rows = [] for name, (n, c) in data.items(): p = c / n se = np.sqrt(p * (1 - p) / n) # standard error of a proportion rows.append({"group": name, "p": p*100, "lo": (p - 1.96*se)*100, "hi": (p + 1.96*se)*100}) df = pd.DataFrame(rows) print(df.round(2).to_string(index=False)) n1, c1 = data["Control"]; n2, c2 = data["Variant A"] p1, p2 = c1/n1, c2/n2 se_diff = np.sqrt(p1*(1-p1)/n1 + p2*(1-p2)/n2) z = (p2 - p1) / se_diff print(f"\nz = {z:.3f}, p-value = {2*(1-stats.norm.cdf(abs(z))):.4f}") fig, ax = plt.subplots(figsize=(7,3.1)) ax.errorbar(df["p"], range(len(df)), xerr=[df["p"]-df["lo"], df["hi"]-df["p"]], fmt="o", capsize=6, color="#5B5FE9") ax.set_yticks(range(len(df))); ax.set_yticklabels(df["group"]) plt.show()
RFM splits the base along three axes: Recency, Frequency, Monetary. Each axis is cut into quintiles with qcut, and the scores combine into segments.
import numpy as np, pandas as pd import matplotlib.pyplot as plt rng = np.random.default_rng(5) df = pd.DataFrame({ "recency": rng.exponential(30, 800).clip(1, 180).round(0), "frequency": rng.poisson(4, 800) + 1, "monetary": rng.lognormal(3.2, 1.0, 800).round(2), }) # qcut splits into equally sized groups (quintiles). # Recency is reversed: fewer days = better = score 5 df["R"] = pd.qcut(df["recency"], 5, labels=[5,4,3,2,1]).astype(int) df["F"] = pd.qcut(df["frequency"].rank(method="first"), 5, labels=[1,2,3,4,5]).astype(int) df["M"] = pd.qcut(df["monetary"], 5, labels=[1,2,3,4,5]).astype(int) def segment(r): if r.R >= 4 and r.F >= 4: return "Champions" if r.R >= 4 and r.F <= 2: return "New" if r.R <= 2 and r.F >= 4: return "At risk" if r.R <= 2 and r.F <= 2: return "Dormant" return "Middle" df["segment"] = df.apply(segment, axis=1) print(df.groupby("segment").agg( users=("recency","size"), avg_monetary=("monetary","mean"), ).round(1).sort_values("users", ascending=False).to_string()) fig, ax = plt.subplots(figsize=(7,3.4)) for name, g in df.groupby("segment"): ax.scatter(g["recency"], g["frequency"], s=np.sqrt(g["monetary"])*3.2, alpha=.55, label=name) ax.legend(); plt.show()
The technique: smooth the series with a rolling mean, subtract it from the actual values, and flag points where the deviation exceeds a few standard deviations.
import numpy as np, pandas as pd import matplotlib.pyplot as plt rng = np.random.default_rng(3) days = pd.date_range("2026-05-01", periods=90) values = 1000 + np.arange(90)*4 + rng.normal(0, 45, 90) values[62:66] -= 380 # an incident happened here s = pd.Series(values.round(0), index=days) roll = s.rolling(7, center=True).mean() # smooth with a weekly window resid = s - roll # deviation from the trend thr = 2.5 * resid.std() # threshold: 2.5 sigma anom = s[np.abs(resid) > thr] print(f"threshold: +/-{thr:.0f} points found: {len(anom)}") print(anom.to_string()) fig, ax = plt.subplots(figsize=(7.4,3.2)) ax.plot(s.index, s.values, color="#a59cf7", lw=1.1, label="actual") ax.plot(roll.index, roll.values, color="#5B5FE9", lw=2.4, label="rolling mean") ax.scatter(anom.index, anom.values, color="#E11D48", s=52, zorder=5, label="anomaly") ax.legend(); plt.show()
Three techniques that separate an analyst's chart from a spreadsheet default. Each solves a specific perception problem.
Beyond three series, lines on a single chart become unreadable. The fix is to split into small panels and highlight one segment per panel, keeping the others as grey context.
import matplotlib.pyplot as plt segs = ["Mobile iOS", "Mobile Android", "Desktop", "Tablet"] fig, axes = plt.subplots(2, 2, figsize=(7.4,4.0), sharex=True, sharey=True) for ax, seg in zip(axes.ravel(), segs): for other in segs: # grey context lines if other != seg: g = d[d.segment == other] ax.plot(g["week"], g["retention"], color="#e6e8f0", lw=1.2) g = d[d.segment == seg] # the highlighted one ax.plot(g["week"], g["retention"], color="#5B5FE9", lw=2.4) ax.set_title(seg, fontsize=9.5, loc="left", fontweight="bold") fig.supxlabel("Week"); fig.supylabel("Retention, %") plt.tight_layout(); plt.show()
Two bars of averages hide everything interesting. A box plus the raw points plus a mean marker shows shape, spread and outliers at once.
import seaborn as sns import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(7,3.1)) sns.boxplot(data=dd, x="value", y="group", hue="group", legend=False, ax=ax, width=.45, showfliers=False, palette=["#5b6178", "#5B5FE9"], linewidth=1.3) sns.stripplot(data=dd, x="value", y="group", ax=ax, size=2.6, alpha=.32, color="#1e2233", jitter=.28) for i, (name, g) in enumerate(dd.groupby("group")): # mean as a diamond ax.scatter(g["value"].mean(), i, marker="D", s=52, color="#E11D48", zorder=6, edgecolors="white") ax.set_xlim(0, dd["value"].quantile(.985)) plt.show()
The question "revenue grew by 73k — where from?" is best answered by a waterfall: every contribution is separate, and gains are distinguished from losses by colour.
import matplotlib.pyplot as plt steps = ["Revenue\nMay","New\ncustomers","Win-back", "Upgrades","Downgrades","Churn","Revenue\nJune"] start = 420 deltas = [85, 22, 48, -19, -63] fig, ax = plt.subplots(figsize=(7.4,3.4)) cum = start ax.bar(0, start, color="#5B5FE9", width=.6) for i, v in enumerate(deltas, start=1): bottom = cum if v > 0 else cum + v # the key line of a waterfall ax.bar(i, abs(v), bottom=bottom, width=.6, color="#059669" if v > 0 else "#E11D48") ax.plot([i-1+.3, i-.3], [cum, cum], color="#5b6178", lw=1, ls=":") cum += v ax.bar(len(steps)-1, cum, color="#5B5FE9", width=.6) ax.set_xticks(range(len(steps))); ax.set_xticklabels(steps, fontsize=8.3) plt.show()
cum + v and its height is abs(v). Passing a negative height together with a shifted base draws the bar at half its size — a classic bug when building a waterfall by hand.| The title is the conclusion, not the label | "Variants overlap the control — no winner" beats "Conversion by group". The reader gets the thought, not the raw data. |
| Remove the frame and surplus gridlines | Anything that carries no information steals attention from what does. |
| Label values directly on the chart | It saves the reader from looking back and forth to the axis. |
| Do not truncate the Y axis on bar charts | Bars are compared by length, so a cut axis exaggerates the difference. For lines it is acceptable. |
| Colour is meaning, not decoration | One accent for the point being made, grey for context. A rainbow without meaning slows reading down. |
| Show uncertainty | Confidence intervals are more honest than point estimates and prevent premature conclusions. |
| Mistake | What it costs you | Do this instead |
|---|---|---|
count() instead of nunique() | The funnel inflates and conversion is overstated | Count distinct users |
| Reporting only the mean | A few large customers mask a decline in everyone else | Median, percentiles, histogram |
| Overall retention instead of cohorts | Mixes old and new users, so improvements stay invisible | A cohort matrix |
| A loop instead of vectorisation | Slow, longer to write, more room for error | Operate on the whole column |
Ignoring SettingWithCopyWarning | Values silently fail to be written | .copy() or .loc[...] |
Never checking isna() | mean() quietly skips gaps and the metric drifts | Count missing values before calculating |
| Not fixing the random seed | Results change between runs and cannot be reproduced | default_rng(42) |
| Comparing groups of different size by absolute numbers | The bigger segment always appears to win | Compare shares, normalise |