Drawing on j-richard-snape's answer, I wrote a function that calculates discretized histogram bins. This function is essentially a wrapper for numpy's histogram_bin_edges(), and allows you to specify the bins' data range and estimation method.
The function first calculates the discretization size (if needed), then gets the suggested bins using histogram_bin_edges(), and finally, recalculates the suggested bins using the closest available discretized bin width.
import numpy as np
def discretized_bin_edges(a, discretization_size=None, bins=10,
range=None, weights=None):
"""Wrapper for numpy.histogram_bin_edges() that forces bin
widths to be a multiple of discretization_size.
"""
if discretization_size is None:
# calculate the minimum distance between values
discretization_size = np.diff(np.unique(a)).min()
if range is None:
range = (a.min(), a.max())
# get suggested bin with
bins = np.histogram_bin_edges(a, bins, range, weights)
bin_width = bins[1] - bins[0]
# calculate the nearest discretized bin width
discretized_bin_width = (
discretization_size *
max(1, round(bin_width / discretization_size))
)
# calculate the discretized bins
left_of_first_bin = range[0] - float(discretization_size)/2
right_of_last_bin = range[1] + float(discretization_size)/2
discretized_bins = np.arange(
left_of_first_bin,
right_of_last_bin + discretized_bin_width,
discretized_bin_width
)
return discretized_bins
Examples
OP's uniform distribution
Discretizing and centering the bins shows the true underlying distribution.

Number of heads in 50 fair coin tosses
The histogram calculation chooses a bin width < 1, resulting in obvious data gaps.

Gamma distribution with 100k samples
Gaps can also occur when the bin width is between two multiples of the discretization size.

Code to create figures
import matplotlib.pyplot as plt
np.random.seed(6389)
def compare_binning(data, discretization_size=None, bins=10, range=None):
fig, (ax1, ax2) = plt.subplots(ncols=2, sharey=True, figsize=(9.6, 4.8),)
# first, plot without discretized binning
ax1.hist(data, bins=bins, range=range, edgecolor='black')
ax1.set_title('Standard binning')
ax1.set_ylabel('Frequency')
# now plot with the discretized bins
dbins = discretized_bin_edges(data, discretization_size, bins, range)
ax2.hist(data, bins=dbins, edgecolor='black')
ax2.set_title('Discretized binning')
# show the plot
plt.subplots_adjust(wspace=.1)
plt.show()
plt.close()
# Example 1
data = np.array(range(11))
compare_binning(data)
# Example 2
data = np.random.binomial(n=50, p=1/2, size=1000)
compare_binning(data, bins='auto')
# Example 3
data = np.random.gamma(shape=2, scale=20, size=100000).round()
rmin, rmax = np.percentile(data, q=(0, 99))
compare_binning(data, bins='auto', range=(rmin, rmax))