Skip to content

Visualisation: looking at the data

Chapter 4 14 code blocks 3146 words

The code in this chapter uses these filesrun it on your own machine

The scene: you can't read 1200 rows

The data has arrived. You ran df.head() and saw 5 rows. You ran df.describe() and got a table of numbers.

But one question is still open: what does this data actually look like?

Are prices bunched together or spread out? Are there two or three strange values? Does the distance to the metro affect the price?

You can't answer these questions by staring at a table. Looking at a chart, you answer them in a second.

This chapter completes step 1 of the protocol: you opened the file, now look at it.

Column names

The data is synthetic Tashkent rental listings, and its column names are Uzbek, so they also appear as axis labels below. tuman district, xonalar rooms, maydon_m2 area in m², qavat floor, qavatlar_soni floors in the building, metro_km distance to the metro, mebel furnished (0/1), narx_usd monthly rent in USD. The target ijaraga_berildi_30k means rented within 30 days.

Why seaborn, isn't matplotlib enough

matplotlib is the foundation, and it can do anything. But it makes you write a lot of code even for simple things.

One question: "what is the average price in each district?"

python25 lines
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

sns.set_theme(style="whitegrid", font_scale=1.05)
df = pd.read_csv(DATA / "uy_ijara.csv")

# The MATPLOTLIB way: group by hand, place by hand
figure, axes = plt.subplots(1, 2, figsize=(14, 4.8))

g = df.groupby("tuman").narx_usd.mean().sort_values()
axes[0].barh(range(len(g)), g.values, color="#5f6368")
axes[0].set_yticks(range(len(g)))
axes[0].set_yticklabels(g.index)
axes[0].set_xlabel("Average price (USD)")
axes[0].set_title("matplotlib: 6 lines of code", fontweight="bold")

# The SEABORN way: one line
sns.barplot(data=df, y="tuman", x="narx_usd", estimator="mean",
            errorbar=None, ax=axes[1], order=g.index, color="#1a73e8")
axes[1].set_xlabel("Average price (USD)")
axes[1].set_title("seaborn: 1 line of code", fontweight="bold")

fig("matplotlib_vs_seaborn.png", caption="The same result. On the left we group by hand, on the right seaborn groups for us.")

The same result. On the left we group by hand, on the right seaborn groups for us.

The same result. On the left we group by hand, on the right seaborn groups for us.

Seaborn understands a DataFrame: you give it a column name, and it does the grouping, computing and drawing itself.

The rule

matplotlib is for fine-tuning (axis names, colours, sizes). seaborn is for creating the chart.

They work together: seaborn draws, matplotlib adjusts. That's what the code above does too.

Distribution: what does one column look like

The first question is always this: how are the values spread?

python12 lines
figure, axes = plt.subplots(1, 3, figsize=(15, 4.2))

sns.histplot(data=df, x="narx_usd", bins=35, kde=True, ax=axes[0], color="#1a73e8")
axes[0].set_title("histplot: shape")

sns.boxplot(data=df, x="narx_usd", ax=axes[1], color="#43a047")
axes[1].set_title("boxplot: median, quartiles, outliers")

sns.violinplot(data=df, x="narx_usd", ax=axes[2], color="#8e24aa")
axes[2].set_title("violinplot: shape + quartiles")

fig("taqsimot.png", caption="One column, three views. histplot shows the shape, boxplot shows the extreme values.")

One column, three views. histplot shows the shape, boxplot shows the extreme values.

One column, three views. histplot shows the shape, boxplot shows the extreme values.

Now the most important habit: check what the chart told you with a number.

python7 lines
print(df.narx_usd.describe().round(1).to_string())
print()
q1, q3 = df.narx_usd.quantile([0.25, 0.75])
iqr = q3 - q1
outliers = df[(df.narx_usd < q1 - 1.5 * iqr) | (df.narx_usd > q3 + 1.5 * iqr)]
print(f"Interquartile range (IQR): {iqr:.0f}")
print(f"Rows the boxplot marks as outliers: {len(outliers)} ({len(outliers)/len(df):.1%})")
Output
count    1200.0
mean      776.1
std       229.5
min       225.0
25%       608.8
50%       767.5
75%       935.0
max      1515.0

Interquartile range (IQR): 326
Rows the boxplot marks as outliers: 4 (0.3%)

An outlier is NOT always a mistake

The boxplot showed dots, but that doesn't mean "bad data". Tashkent really does have expensive flats.

The rule: read an outlier before you drop it. If it's real, keep it. If price = 999999, it's a data-entry error; drop it.

python11 lines
figure, axes = plt.subplots(1, 2, figsize=(14, 4.8))

sns.scatterplot(data=df, x="maydon_m2", y="narx_usd", hue="mebel",
                alpha=.55, ax=axes[0], palette=["#c5221f", "#137333"])
axes[0].set_title("Area and price (colour = furnished)")

sns.scatterplot(data=df, x="metro_km", y="narx_usd", alpha=.45, ax=axes[1], color="#1a73e8")
sns.regplot(data=df, x="metro_km", y="narx_usd", scatter=False, ax=axes[1], color="#c5221f")
axes[1].set_title("Distance to metro and price (red line = trend)")

fig("boglanish.png", caption="Left: price rises with area. Right: price falls further from the metro, but the spread is wide.")

Left: price rises with area. Right: price falls further from the metro, but the spread is wide.

Left: price rises with area. Right: price falls further from the metro, but the spread is wide.

The chart showed a trend. Now we measure its strength:

python4 lines
num_cols = ["xonalar", "maydon_m2", "qavat", "qavatlar_soni", "metro_km", "mebel", "narx_usd"]
corr = df[num_cols].corr()
print("Correlation with narx_usd:")
print(corr["narx_usd"].drop("narx_usd").sort_values(ascending=False).round(3).to_string())
Output
Correlation with narx_usd:
maydon_m2        0.889
xonalar          0.869
mebel            0.206
qavatlar_soni    0.076
qavat            0.062
metro_km        -0.156
python5 lines
figure, ax = plt.subplots(figsize=(8.5, 6.5))
sns.heatmap(corr, annot=True, fmt=".2f", cmap="RdBu_r", center=0,
            square=True, linewidths=.5, ax=ax, cbar_kws={"shrink": .8})
ax.set_title("Correlation matrix", fontweight="bold")
fig("heatmap.png", caption="Red = positive relationship, blue = negative. Note the 0.94 between xonalar and maydon_m2.")

Red = positive relationship, blue = negative. Note the 0.94 between xonalar and maydon_m2.

Red = positive relationship, blue = negative. Note the 0.94 between xonalar and maydon_m2.

On a heatmap, look AWAY FROM THE DIAGONAL first

The diagonal is always 1.00: every column is perfectly related to itself. That tells you nothing.

The interesting spot: 0.94 between xonalar and maydon_m2. They say almost the same thing (a big flat has many rooms).

This is multicollinearity. It makes a linear model's coefficients unstable (Chapter 17), but it often doesn't affect prediction accuracy. In Chapter 42 I got exactly this wrong.

Category charts

python16 lines
figure, axes = plt.subplots(1, 3, figsize=(16, 4.5))

sns.countplot(data=df, y="tuman", ax=axes[0], color="#5f6368",
              order=df.tuman.value_counts().index)
axes[0].set_title("countplot: number of listings")

sns.boxplot(data=df, x="xonalar", y="narx_usd", ax=axes[1], hue="xonalar",
            legend=False, palette="Blues")
axes[1].set_title("boxplot: rooms and price")

sns.barplot(data=df, x="xonalar", y="ijaraga_berildi_30k", ax=axes[2],
            errorbar=("ci", 95), color="#137333")
axes[2].set_ylabel("Share rented fast")
axes[2].set_title("barplot: with a confidence interval")

fig("kategoriya.png", caption="countplot shows counts, boxplot the distribution, barplot the mean (with a confidence interval).")

countplot shows counts, boxplot the distribution, barplot the mean (with a confidence interval).

countplot shows counts, boxplot the distribution, barplot the mean (with a confidence interval).

The small vertical lines on the third chart are 95% confidence intervals. They matter: if two bars' lines overlap, the difference between them is not reliable.

python7 lines
print("Share rented fast, by number of rooms:")
g = df.groupby("xonalar").agg(n=("ijaraga_berildi_30k", "size"),
                              fast_share=("ijaraga_berildi_30k", "mean"))
g["fast_share"] = g.fast_share.round(3)
print(g.to_string())
print()
print("NOTE: there are few 5-room flats, so their share is not trustworthy.")
Output
Share rented fast, by number of rooms:
           n  fast_share
xonalar                 
1        222       0.860
2        412       0.636
3        345       0.351
4        174       0.178
5         47       0.064

NOTE: there are few 5-room flats, so their share is not trustworthy.

Don't trust the average of a small group

In the table above there are only a few dozen 5-room flats. Their "share rented fast" depends heavily on chance.

The rule: always show the number of rows in each group. barplot draws a pretty bar, but it doesn't tell you whether there are 3 rows or 300 behind it.

Many dimensions: hue, col, row

Seaborn's greatest strength is showing three or four dimensions in one line.

python7 lines
subset = df[df.xonalar.isin([1, 2, 3])]
g = sns.relplot(data=subset, x="maydon_m2", y="narx_usd",
                hue="mebel", col="xonalar", kind="scatter",
                height=3.8, aspect=1.05, alpha=.6,
                palette=["#c5221f", "#137333"])
g.figure.suptitle("Area and price, split by number of rooms", y=1.04, fontweight="bold")
fig("relplot.png", caption="col='xonalar' created three panels. hue='mebel' added a fourth dimension as colour.")

col='xonalar' created three panels. hue='mebel' added a fourth dimension as colour.

col='xonalar' created three panels. hue='mebel' added a fourth dimension as colour.

Four dimensions in one chart: area (x), price (y), furnished (colour), rooms (panel).

Which chart, when

python11 lines
show(pd.DataFrame([
    {"Question": "How is one numeric column distributed?", "Chart": "histplot", "Extra": "kde=True smooths the shape"},
    {"Question": "Are there outliers?", "Chart": "boxplot", "Extra": "Dots = outside the IQR"},
    {"Question": "Is there a link between two numbers?", "Chart": "scatterplot", "Extra": "regplot adds a trend line"},
    {"Question": "All numeric columns against each other?", "Chart": "heatmap(corr)", "Extra": "or pairplot (slow)"},
    {"Question": "How many rows per category?", "Chart": "countplot", "Extra": "sort with order="},
    {"Question": "The mean per category?", "Chart": "barplot", "Extra": "DON'T turn off the errorbar"},
    {"Question": "The distribution per category?", "Chart": "boxplot / violinplot", "Extra": "tells you more than a barplot"},
    {"Question": "Change over time?", "Chart": "lineplot", "Extra": "the x axis should be a date"},
    {"Question": "Three or four dimensions together?", "Chart": "relplot / catplot", "Extra": "hue, col, row"},
]))
QuestionChartExtra
How is one numeric column distributed?histplotkde=True smooths the shape
Are there outliers?boxplotDots = outside the IQR
Is there a link between two numbers?scatterplotregplot adds a trend line
All numeric columns against each other?heatmap(corr)or pairplot (slow)
How many rows per category?countplotsort with order=
The mean per category?barplotDON'T turn off the errorbar
The distribution per category?boxplot / violinplottells you more than a barplot
Change over time?lineplotthe x axis should be a date
Three or four dimensions together?relplot / catplothue, col, row

The most important chart for ML: splitting by the target

Before moving on to a model, draw one chart: does each feature separate the target?

python7 lines
figure, axes = plt.subplots(1, 4, figsize=(17, 4))
for ax, c in zip(axes, ["maydon_m2", "metro_km", "narx_usd", "qavat"]):
    sns.boxplot(data=df, x="ijaraga_berildi_30k", y=c, ax=ax,
                hue="ijaraga_berildi_30k", legend=False, palette=["#c5221f", "#137333"])
    ax.set_xlabel("0 = went slow   1 = went fast")
    ax.set_title(c)
fig("target_ajratish.png", caption="The further apart the boxes are, the more useful that feature is.")

The further apart the boxes are, the more useful that feature is.

The further apart the boxes are, the more useful that feature is.

python9 lines
print("How strongly each feature separates the target:")
rows = []
for c in ["maydon_m2", "metro_km", "narx_usd", "qavat", "xonalar", "mebel"]:
    a = df[df.ijaraga_berildi_30k == 0][c].mean()
    b = df[df.ijaraga_berildi_30k == 1][c].mean()
    pooled_std = df[c].std()
    rows.append({"Column": c, "Slow mean": round(a, 2), "Fast mean": round(b, 2),
                 "Separation": round(abs(b - a) / pooled_std, 3)})
show(pd.DataFrame(rows).sort_values("Separation", ascending=False))
Output
How strongly each feature separates the target:
ColumnSlow meanFast meanSeparation
narx_usd894.46660.861.018
maydon_m267.2247.410.983
xonalar3.0320.963
qavat4.995.210.06
metro_km2.622.550.048
mebel0.610.620.018

The last column is the difference scaled by the standard deviation. The larger it is, the more useful the feature.

Notice: narx_usd separates best. That makes sense: a cheap flat goes faster. But we don't use it as a feature, because it is the second target (Chapter 1, step 7).

Polishing for a report

python13 lines
sns.set_theme(style="whitegrid", font_scale=1.1)
figure, ax = plt.subplots(figsize=(10, 5))
g = df.groupby("tuman").agg(avg=("narx_usd", "mean"),
                            n=("narx_usd", "size")).sort_values("avg")
sns.barplot(x=g.avg, y=g.index, ax=ax, color="#1a73e8")
for i, (v, n) in enumerate(zip(g.avg, g.n)):
    ax.text(v + 8, i, f"{v:.0f} USD  (n={n})", va="center", fontsize=9.5, color="#333")
ax.set_xlabel("Average rent, USD/month")
ax.set_ylabel("")
ax.set_title("Average rent by Tashkent district", fontweight="bold", pad=14)
ax.set_xlim(0, g.avg.max() * 1.28)
sns.despine(left=True, bottom=True)
fig("hisobot.png", caption="A report-ready chart: the values are written, the group size (n) is shown, the clutter is removed.")

A report-ready chart: the values are written, the group size (n) is shown, the clutter is removed.

A report-ready chart: the values are written, the group size (n) is shown, the clutter is removed.

The three rules of a report chart: write the value, show the group size (n=), remove the clutter.

What we learned today

python9 lines
show(pd.DataFrame([
    {"Rule": "Look first, build the model later", "Why": "A chart tells you more in a second than a table"},
    {"Rule": "seaborn speaks DataFrame", "Why": "You give a column name; it groups and draws"},
    {"Rule": "Check the chart WITH A NUMBER", "Why": "Eyes deceive; describe() and corr() confirm"},
    {"Rule": "Show the group size", "Why": "The mean of a 3-row group means nothing"},
    {"Rule": "Don't turn off the errorbar", "Why": "Overlapping intervals = the difference isn't reliable"},
    {"Rule": "Read an outlier before dropping it", "Why": "Not all of them are mistakes; some are real"},
    {"Rule": "Split by the target", "Why": "It shows in advance which features are useful"},
]))
RuleWhy
Look first, build the model laterA chart tells you more in a second than a table
seaborn speaks DataFrameYou give a column name; it groups and draws
Check the chart WITH A NUMBEREyes deceive; describe() and corr() confirm
Show the group sizeThe mean of a 3-row group means nothing
Don't turn off the errorbarOverlapping intervals = the difference isn't reliable
Read an outlier before dropping itNot all of them are mistakes; some are real
Split by the targetIt shows in advance which features are useful

Next chapter: we looked at the data and found the problems. Now we fix them, getting the data into a shape the model accepts.

Questions about this chapter?

The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.

Write on Telegram Contents