EDA and Feature Engineeringpandas 3.0 · scikit-learn 1.9 · seaborn 0.13 · Python 3.12+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
72 small wins to finish your pathNext lesson →

End of distribution imputation

End of distribution imputation fills every gap with a value taken from the far tail of the column, usually the mean plus three standard deviations, so the filled rows sit apart from the real ones and a model can tell them from the rest without a second column.

Last updated: 12 Oct, 2026 · pandas 3.0

Capturing NaN with an indicator column kept the missingness signal by adding a column. This technique keeps the same signal inside the column that was filled, by choosing a fill value that no real row would have.

A normal curve over the recorded ages with the mean marked in the middle and three standard deviations stepped off to the right, reaching the fill value of 73.2786. The missing rows are shown being pushed out to that point, far from the bulk of the data, while the matching point three standard deviations below the mean falls at -13.8804, which is impossible for an age and is why only the upper tail is used.

Syntax: the fill value is df['Age'].mean() + 3 * df['Age'].std(), and the fill itself is an ordinary fillna. The three is a convention borrowed from the normal distribution, where about 99.7 percent of values lie within three standard deviations of the mean.

python
mean, std = df['Age'].mean(), df['Age'].std()
upper = mean + 3 * std            # the far right tail
lower = mean - 3 * std            # the far left tail
df['Age_end'] = df['Age'].fillna(upper)

Loading the Titanic data

The Titanic table is 891 passengers. Age is missing for 177 of them, Cabin for 687 and Embarked for 2, which makes it the right table to learn these techniques on. It loads from its GitHub URL, so there is nothing to download. Run this block first, then the example under it.

python
import pandas as pd
import matplotlib
matplotlib.use('svg')
import matplotlib.pyplot as plt

url = ("https://raw.githubusercontent.com/krishnaik06/"
       "Feature-Engineering-Live-sessions/master/titanic.csv")
df = pd.read_csv(url, usecols=['Survived', 'Age', 'Fare'])

Working out the two fences

Both ends are computed, and then the data decides which is usable. An age of -13.8804 does not exist, so the lower fence is useless here and the upper one is the fill value. On a column that can go negative, either end works.

ExampleThree standard deviations either side of the mean
mean, std = df['Age'].mean(), df['Age'].std()
upper = mean + 3 * std
lower = mean - 3 * std
print(f"mean {mean:.4f}   std {std:.4f}")
print(f"lower fence {lower:.4f}   upper fence {upper:.4f}")
print("recorded ages above the upper fence:", int((df['Age'] > upper).sum()))
print("recorded ages below the lower fence:", int((df['Age'] < lower).sum()))
  • The upper fence is 73.2786. Two recorded passengers are older than that, so the fill value lands in a region the column does reach, but hardly.
  • The lower fence is -13.8804. Nothing can be there, which is the usual outcome for a quantity that cannot be negative.
  • The fill value is not an outlier by accident. It is chosen to be one, so that anything reading the column can separate the filled rows.

What the fill does to the histogram

Filling 177 rows with 73.2786 puts a bar at the right hand end of the chart that is taller than anything in the hump. The rest of the shape is untouched, which is the point: the real data keeps its form and the invented rows stand to one side.

ExampleThe same column, before and after
upper = df['Age'].mean() + 3 * df['Age'].std()
df['Age_end'] = df['Age'].fillna(upper)
print("fill value:", round(upper, 4))
print("passengers now above 73:", int((df['Age_end'] > 73).sum()))

fig, axes = plt.subplots(1, 2, figsize=(11, 4), sharey=True)
df['Age'].hist(bins=50, ax=axes[0], color='#3a6fd8')
axes[0].set_title('Age, the 714 recorded')
df['Age_end'].hist(bins=50, ax=axes[1], color='#d64541')
axes[1].set_title('Age after end of distribution imputation')
for a in axes:
    a.set_xlabel('Age')
fig.savefig('chart.svg')
Two histograms of passenger age sharing a vertical scale. The left one, of the 714 recorded ages, peaks between 20 and 32 and thins out towards 80. The right one, after the fill, is the same hump with one red bar of 177 passengers standing alone at 73.28, taller than every bar in the hump.

179 passengers now sit above 73, against the two that did before. A model that splits on Age can isolate those rows with one cut, which is how the missingness survives inside a single column.

The side effect on an outlier check

The filled rows are deliberate outliers, so every outlier measure taken after this step changes. A box plot of the result is the clearest view of it: the box grows, the fences move outwards, and the points that used to be drawn beyond the whisker are swallowed.

ExampleWhat each fill does to the quartiles
upper = df['Age'].mean() + 3 * df['Age'].std()
df['Age_end'] = df['Age'].fillna(upper)
df['Age_median'] = df['Age'].fillna(df['Age'].median())
for name in ['Age', 'Age_median', 'Age_end']:
    q1, q3 = df[name].quantile([0.25, 0.75])
    print(f"{name:11s} box {q1:7.4f} to {q3:7.4f},"
          f" whisker reaches {q3 + 1.5 * (q3 - q1):8.4f}")

fig, ax = plt.subplots(figsize=(7, 4))
df.boxplot(column=['Age', 'Age_median', 'Age_end'], ax=ax)
ax.set_title('The same column, three ways')
fig.savefig('chart.svg')
Three box plots side by side. Age has a box from 20 to 38 with points above 65. Age_median has a shorter box and many more points drawn beyond its whiskers. Age_end has a box stretched from 22 to 54 with a whisker reaching far above every value, so no point is drawn outside it.
  • The box grows from 20.1250 to 38.0000 into 22.0000 to 54.0000. The third quartile moves because 177 rows were added at the top of the range.
  • The whisker now reaches 102.0000, where it reached 64.8125 before. The two two oldest passengers are no longer drawn as separate points.
  • Nothing is an outlier any more. That is the technique working as designed and a trap if you then run an outlier check and conclude the column is clean.
  • Median imputation moves the same fence the other way. Its box shrinks to 22.0000 to 35.0000 and its whisker reaches only 54.5000, so ages that were ordinary before are now drawn as outliers.

End of distribution vs median vs indicator

Median imputationEnd of distributionIndicator plus fill
Fill valuethe middle of the columnthe far tailthe middle, plus a flag column
Extra columnsnonenoneone per filled column
Missingness recoverablenoyes, by its valueyes, from the flag
Distorts the spreadshrinks itstretches itshrinks it
Breaks outlier checksnoyesno
Use whenthe gaps are random and fewthe gaps carry meaning and no extra column is wantedthe gaps carry meaning

Where this technique is the right one

  • Tree based models. A tree can split off the far tail in one cut, so the filled rows are separated with no extra feature.
  • A wide table where indicators would double the columns. The signal is kept without adding anything.
  • A column whose gaps you suspect are informative, which is the MNAR case from Why values go missing.
Watch out. The fill value is an extreme number, so anything that scales or centres the column afterwards is affected by it. Standardisation uses the mean and the standard deviation, and this technique moves both of them by design. Fill first, then scale, and never read an outlier report taken after this step as a statement about the real data.
Try it yourself
  • Change the 3 to a 2 and read how far the fill value moves.
  • Fill with the lower fence instead and check what a negative age does to describe().
  • Run the same calculation on Fare and compare the fence with its real maximum of 512.3292.

You understood something today that you didn't yesterday.