Skip to main content
MahiDev — Learn. Build. Become AI-ready.

Lesson 07

KDE and ECDF Plots

Draw smooth density curves with sns.kdeplot, tune them with bw_adjust, compare groups, and use ecdfplot to read percentiles straight off a curve.

Intermediate25 min
KDE and ECDF Plots hero Module 26 Lesson 7KDE and ECDF Plots hero Module 26 Lesson 7

A histogram's shape depends on where the bins fall. A kernel density estimate, or KDE, avoids bins: it places a small smooth bump on every value and adds the bumps up. The result is a curve whose area is 1. An ECDF is the other view: for every value, what share of the rows is at or below it.

Three overlapping filled density curves of quiz scores, one per trackThree overlapping filled density curves of quiz scores, one per track

A first density curve

kdeplot draws one curve for the column. The y axis is density, not a count, and the area under the whole curve is 1. Integrating the drawn line confirms it.

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

rng = np.random.default_rng(26)
study = pd.DataFrame({
    "track": rng.choice(["Python", "Data", "Web"], 120),
    "mode": rng.choice(["online", "classroom"], 120),
    "week": rng.integers(1, 7, 120),
})
study["hours"] = (rng.uniform(1, 6, 120) + study["week"] * 0.8).round(1)
study["score"] = (35 + study["hours"] * 4.5 + rng.normal(0, 7, 120)).clip(0, 100).round()

ax = sns.kdeplot(data=study, x="score")
plt.show()

x, y = ax.lines[0].get_data()
print(round(np.trapz(y, x), 3))

Output:

text
1.0

Smoothing with bw_adjust

The bandwidth is how wide each bump is. bw_adjust scales Seaborn's automatic choice: below 1 follows the data closely and gets wiggly, above 1 smooths more and can hide real bumps.

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

rng = np.random.default_rng(26)
study = pd.DataFrame({
    "track": rng.choice(["Python", "Data", "Web"], 120),
    "mode": rng.choice(["online", "classroom"], 120),
    "week": rng.integers(1, 7, 120),
})
study["hours"] = (rng.uniform(1, 6, 120) + study["week"] * 0.8).round(1)
study["score"] = (35 + study["hours"] * 4.5 + rng.normal(0, 7, 120)).clip(0, 100).round()

for adjust in [0.3, 1, 2.5]:
    sns.kdeplot(data=study, x="score", bw_adjust=adjust, label=f"bw_adjust={adjust}")
plt.legend()
plt.show()

print("same data, three smoothings")

Output:

text
same data, three smoothings

Compare groups

With hue, each group gets a curve. By default the curves share one total area of 1, so a larger group gets a taller curve. common_norm=False gives each group its own area of 1 so you compare shapes, not sizes.

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

rng = np.random.default_rng(26)
study = pd.DataFrame({
    "track": rng.choice(["Python", "Data", "Web"], 120),
    "mode": rng.choice(["online", "classroom"], 120),
    "week": rng.integers(1, 7, 120),
})
study["hours"] = (rng.uniform(1, 6, 120) + study["week"] * 0.8).round(1)
study["score"] = (35 + study["hours"] * 4.5 + rng.normal(0, 7, 120)).clip(0, 100).round()

sns.kdeplot(data=study, x="score", hue="track", fill=True, common_norm=False, alpha=0.3)
plt.show()

print(study["track"].value_counts())

Output:

text
track
Web       51
Python    35
Data      34
Name: count, dtype: int64

The curve runs past the data

Bumps near the edges spill beyond the smallest and largest values, so the curve suggests scores below 40 that nobody got. cut=0 stops the curve at the data's range, and clip=(0, 100) stops it at limits you know.

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

rng = np.random.default_rng(26)
study = pd.DataFrame({
    "track": rng.choice(["Python", "Data", "Web"], 120),
    "mode": rng.choice(["online", "classroom"], 120),
    "week": rng.integers(1, 7, 120),
})
study["hours"] = (rng.uniform(1, 6, 120) + study["week"] * 0.8).round(1)
study["score"] = (35 + study["hours"] * 4.5 + rng.normal(0, 7, 120)).clip(0, 100).round()

ax = sns.kdeplot(data=study, x="score", label="default")
sns.kdeplot(data=study, x="score", cut=0, label="cut=0", ax=ax)
plt.legend()
plt.show()

print("data range:", study["score"].min(), study["score"].max())
print("curves start at:", [round(line.get_xdata()[0], 1) for line in ax.lines])

Output:

text
data range: 40.0 85.0
curves start at: [27.9, 40.0]

Read percentiles with ecdfplot

ecdfplot climbs from 0 to 1. Pick a score on the x axis and the height is the share of rows at or below it. It needs no bins and no smoothing, so it never invents values.

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

rng = np.random.default_rng(26)
study = pd.DataFrame({
    "track": rng.choice(["Python", "Data", "Web"], 120),
    "mode": rng.choice(["online", "classroom"], 120),
    "week": rng.integers(1, 7, 120),
})
study["hours"] = (rng.uniform(1, 6, 120) + study["week"] * 0.8).round(1)
study["score"] = (35 + study["hours"] * 4.5 + rng.normal(0, 7, 120)).clip(0, 100).round()

sns.ecdfplot(data=study, x="score", hue="mode")
plt.axhline(0.5, color="grey", linestyle="--")
plt.show()

print(study.groupby("mode")["score"].median())
print((study["score"] <= 70).mean().round(3))

Output:

text
mode
classroom    66.0
online       65.0
Name: score, dtype: float64
0.708
A cumulative step curve of scores with the median marked at half heightA cumulative step curve of scores with the median marked at half height

Key takeaways

  • A KDE adds a smooth bump per value; its total area is 1

  • bw_adjust below 1 follows the data, above 1 smooths more

  • common_norm=False compares group shapes rather than sizes

  • ecdfplot shows the share of rows at or below each value

  • Next: Count Plots and Bar Plots

  • Previous: Histograms with histplot

Loading playground…

Check your understanding

KDE and ECDF Plots Quiz

What does a KDE place on every value?

1 / 10