Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit bc94215

Browse files
committed
Improve tick subsampling in LogLocator.
Rewrite the logic for subsampling log-scale ticks, so that 1) there's as many ticks as possible without going over numticks (don't draw a single tick when two would fit); 2) the ticks are spaced as much as possible (when two ticks are drawn, put them on the lowest and highest decade, not somewhere midway); 3) if possible, draw ticks on integer multiples of the stride (0, 3, 9 is better than 1, 4, 7). See comments in implementation for details. For the backcompat-oriented "classic-mode", remove the "stride >= numdec" check (which is a much later addition). Test changes: - test_small_range_loglocator was likely previously wrong (the assert did not cover the values of ticks, and all iterations of the loop ended up testing the same condition). Rewrite it fully (in particular to cover the new implementation). - changes because tick_values() now returns exactly one major ticks outside of the (vmin, vmax) range on both sides: TestLogLocator, test_axes::test_auto_numticks_log, test_colorbar::test_colorbar_renorm. - test_colorbar::test_colorbar_autotickslog's tested tick values didn't match what actually got drawn, because rendering the colorbar (here simulated via draw_without_rendering) would make the (first) colorbar slightly smaller (via _ColorbarAxesLocator) and change the available tick space. Furthermore, the second colorbar was wrongly drawn with 3 ticks at [-12, 0, +12] when there's only space for 2; this arose because np.log(1e±12)/np.log(10) = ±11.999... which led to a wrong stride calculation; the new code explicitly uses log10, which is more accurate, when possible, and correctly yields ticks at [-12, 12]. - test_contour::test_contourf_log_extension: Log-normed contour extension remains a bit iffy, because it is implemented by asking a LogLocator for ticks within data range, and then including an extra tick below and above (see also the comment regarding _autolev in LogLocator.tick_values()). This is not really optimal, e.g. that extra tick can be quite far below or above the actual data limits (depending on the stride). Perhaps a better solution would be to just extend the range to the nearest decade, or even just accept that colorbar ticks (like axes ticks) don't necessarily reach the very end of the data range but can stop slightly below. In the meantime, I chose to simply force the contour levels so that the test results matches the old output.
1 parent 0b7a88a commit bc94215

File tree

7 files changed

+167
-63
lines changed

7 files changed

+167
-63
lines changed

doc/users/next_whats_new/logticks.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Improved selection of log-scale ticks
2+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3+
4+
The algorithm for selecting log-scale ticks (on powers of ten) has been
5+
improved. In particular, it will now always draw as many ticks as possible
6+
(e.g., it will not draw a single tick if it was possible to fit two ticks); if
7+
subsampling ticks, it will prefer putting ticks on integer multiples of the
8+
subsampling stride (e.g., it prefers putting ticks at 10\ :sup:`0`, 10\ :sup:`3`,
9+
10\ :sup:`6` rather than 10\ :sup:`1`, 10\ :sup:`4`, 10\ :sup:`7`) if this
10+
results in the same number of ticks at the end; and it is now more robust
11+
against floating-point calculation errors.

lib/matplotlib/tests/test_axes.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7003,6 +7003,7 @@ def test_loglog_nonpos():
70037003
ax.set_xscale("log", nonpositive=mcx)
70047004
if mcy:
70057005
ax.set_yscale("log", nonpositive=mcy)
7006+
ax.set_yticks([1e3, 1e7]) # Backcompat tick selection.
70067007

70077008

70087009
@mpl.style.context('default')
@@ -7132,8 +7133,8 @@ def test_auto_numticks_log():
71327133
fig, ax = plt.subplots()
71337134
mpl.rcParams['axes.autolimit_mode'] = 'round_numbers'
71347135
ax.loglog([1e-20, 1e5], [1e-16, 10])
7135-
assert (np.log10(ax.get_xticks()) == np.arange(-26, 18, 4)).all()
7136-
assert (np.log10(ax.get_yticks()) == np.arange(-20, 10, 3)).all()
7136+
assert_array_equal(np.log10(ax.get_xticks()), np.arange(-26, 11, 4))
7137+
assert_array_equal(np.log10(ax.get_yticks()), np.arange(-20, 5, 3))
71377138

71387139

71397140
def test_broken_barh_empty():

lib/matplotlib/tests/test_colorbar.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -491,12 +491,13 @@ def test_colorbar_autotickslog():
491491
pcm = ax[1].pcolormesh(X, Y, 10**Z, norm=LogNorm())
492492
cbar2 = fig.colorbar(pcm, ax=ax[1], extend='both',
493493
orientation='vertical', shrink=0.4)
494+
495+
fig.draw_without_rendering()
494496
# note only -12 to +12 are visible
495-
np.testing.assert_almost_equal(cbar.ax.yaxis.get_ticklocs(),
496-
10**np.arange(-16., 16.2, 4.))
497-
# note only -24 to +24 are visible
498-
np.testing.assert_almost_equal(cbar2.ax.yaxis.get_ticklocs(),
499-
10**np.arange(-24., 25., 12.))
497+
np.testing.assert_equal(np.log10(cbar.ax.yaxis.get_ticklocs()),
498+
[-18, -12, -6, 0, +6, +12, +18])
499+
np.testing.assert_equal(np.log10(cbar2.ax.yaxis.get_ticklocs()),
500+
[-36, -12, 12, +36])
500501

501502

502503
def test_colorbar_get_ticks():
@@ -597,7 +598,7 @@ def test_colorbar_renorm():
597598
norm = LogNorm(z.min(), z.max())
598599
im.set_norm(norm)
599600
np.testing.assert_allclose(cbar.ax.yaxis.get_majorticklocs(),
600-
np.logspace(-10, 7, 18))
601+
np.logspace(-9, 6, 16))
601602
# note that set_norm removes the FixedLocator...
602603
assert np.isclose(cbar.vmin, z.min())
603604
cbar.set_ticks([1, 2, 3])

lib/matplotlib/tests/test_contour.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,8 +399,11 @@ def test_contourf_log_extension():
399399
levels = np.power(10., levels_exp)
400400

401401
# original data
402+
# FIXME: Force tick locations for now for backcompat with old test
403+
# (log-colorbar extension is not really optimal anyways).
402404
c1 = ax1.contourf(data,
403-
norm=LogNorm(vmin=data.min(), vmax=data.max()))
405+
norm=LogNorm(vmin=data.min(), vmax=data.max()),
406+
locator=mpl.ticker.FixedLocator(10.**np.arange(-8, 12, 2)))
404407
# just show data in levels
405408
c2 = ax2.contourf(data, levels=levels,
406409
norm=LogNorm(vmin=levels.min(), vmax=levels.max()),

lib/matplotlib/tests/test_scale.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@ def test_logscale_mask():
107107
fig, ax = plt.subplots()
108108
ax.plot(np.exp(-xs**2))
109109
fig.canvas.draw()
110-
ax.set(yscale="log")
110+
ax.set(yscale="log",
111+
yticks=10.**np.arange(-300, 0, 24)) # Backcompat tick selection.
111112

112113

113114
def test_extra_kwargs_raise():
@@ -162,6 +163,7 @@ def test_logscale_nonpos_values():
162163

163164
ax4.set_yscale('log')
164165
ax4.set_xscale('log')
166+
ax4.set_yticks([1e-2, 1, 1e+2]) # Backcompat tick selection.
165167

166168

167169
def test_invalid_log_lims():

lib/matplotlib/tests/test_ticker.py

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -332,13 +332,11 @@ def test_basic(self):
332332
with pytest.raises(ValueError):
333333
loc.tick_values(0, 1000)
334334

335-
test_value = np.array([1.00000000e-05, 1.00000000e-03, 1.00000000e-01,
336-
1.00000000e+01, 1.00000000e+03, 1.00000000e+05,
337-
1.00000000e+07, 1.000000000e+09])
335+
test_value = np.array([1e-5, 1e-3, 1e-1, 1e+1, 1e+3, 1e+5, 1e+7])
338336
assert_almost_equal(loc.tick_values(0.001, 1.1e5), test_value)
339337

340338
loc = mticker.LogLocator(base=2)
341-
test_value = np.array([0.5, 1., 2., 4., 8., 16., 32., 64., 128., 256.])
339+
test_value = np.array([.5, 1., 2., 4., 8., 16., 32., 64., 128.])
342340
assert_almost_equal(loc.tick_values(1, 100), test_value)
343341

344342
def test_polar_axes(self):
@@ -377,7 +375,7 @@ def test_tick_values_correct(self):
377375
1.e+01, 2.e+01, 5.e+01, 1.e+02, 2.e+02, 5.e+02,
378376
1.e+03, 2.e+03, 5.e+03, 1.e+04, 2.e+04, 5.e+04,
379377
1.e+05, 2.e+05, 5.e+05, 1.e+06, 2.e+06, 5.e+06,
380-
1.e+07, 2.e+07, 5.e+07, 1.e+08, 2.e+08, 5.e+08])
378+
1.e+07, 2.e+07, 5.e+07])
381379
assert_almost_equal(ll.tick_values(1, 1e7), test_value)
382380

383381
def test_tick_values_not_empty(self):
@@ -387,8 +385,7 @@ def test_tick_values_not_empty(self):
387385
1.e+01, 2.e+01, 5.e+01, 1.e+02, 2.e+02, 5.e+02,
388386
1.e+03, 2.e+03, 5.e+03, 1.e+04, 2.e+04, 5.e+04,
389387
1.e+05, 2.e+05, 5.e+05, 1.e+06, 2.e+06, 5.e+06,
390-
1.e+07, 2.e+07, 5.e+07, 1.e+08, 2.e+08, 5.e+08,
391-
1.e+09, 2.e+09, 5.e+09])
388+
1.e+07, 2.e+07, 5.e+07, 1.e+08, 2.e+08, 5.e+08])
392389
assert_almost_equal(ll.tick_values(1, 1e8), test_value)
393390

394391
def test_multiple_shared_axes(self):
@@ -1913,14 +1910,54 @@ def test_bad_locator_subs(sub):
19131910
ll.set_params(subs=sub)
19141911

19151912

1916-
@pytest.mark.parametrize('numticks', [1, 2, 3, 9])
1913+
@pytest.mark.parametrize("numticks, lims, ticks", [
1914+
(1, (.5, 5), [.1, 1, 10]),
1915+
(2, (.5, 5), [.1, 1, 10]),
1916+
(3, (.5, 5), [.1, 1, 10]),
1917+
(9, (.5, 5), [.1, 1, 10]),
1918+
(1, (.5, 50), [.1, 10, 1_000]),
1919+
(2, (.5, 50), [.1, 1, 10, 100]),
1920+
(3, (.5, 50), [.1, 1, 10, 100]),
1921+
(9, (.5, 50), [.1, 1, 10, 100]),
1922+
(1, (.5, 500), [.1, 10, 1_000]),
1923+
(2, (.5, 500), [.01, 1, 100, 10_000]),
1924+
(3, (.5, 500), [.1, 1, 10, 100, 1_000]),
1925+
(9, (.5, 500), [.1, 1, 10, 100, 1_000]),
1926+
(1, (.5, 5000), [.1, 100, 100_000]),
1927+
(2, (.5, 5000), [.001, 1, 1_000, 1_000_000]),
1928+
(3, (.5, 5000), [.001, 1, 1_000, 1_000_000]),
1929+
(9, (.5, 5000), [.1, 1, 10, 100, 1_000, 10_000]),
1930+
])
19171931
@mpl.style.context('default')
1918-
def test_small_range_loglocator(numticks):
1919-
ll = mticker.LogLocator()
1920-
ll.set_params(numticks=numticks)
1921-
for top in [5, 7, 9, 11, 15, 50, 100, 1000]:
1922-
ticks = ll.tick_values(.5, top)
1923-
assert (np.diff(np.log10(ll.tick_values(6, 150))) == 1).all()
1932+
def test_small_range_loglocator(numticks, lims, ticks):
1933+
ll = mticker.LogLocator(numticks=numticks)
1934+
assert_array_equal(ll.tick_values(*lims), ticks)
1935+
1936+
1937+
@mpl.style.context('default')
1938+
def test_loglocator_properties():
1939+
# Test that LogLocator returns ticks satisfying basic desirable properties
1940+
# for a wide range of inputs.
1941+
max_numticks = 8
1942+
pow_end = 20
1943+
for numticks, (lo, hi) in itertools.product(
1944+
range(1, max_numticks + 1), itertools.combinations(range(pow_end), 2)):
1945+
ll = mticker.LogLocator(numticks=numticks)
1946+
decades = np.log10(ll.tick_values(10**lo, 10**hi)).round().astype(int)
1947+
# There are no more ticks than the requested number, plus exactly one
1948+
# tick below and one tick above the limits.
1949+
assert len(decades) <= numticks + 2
1950+
assert decades[0] < lo <= decades[1]
1951+
assert decades[-2] <= hi < decades[-1]
1952+
stride, = {*np.diff(decades)} # Extract the (constant) stride.
1953+
# Either the ticks are on integer multiples of the stride...
1954+
if not (decades % stride == 0).all():
1955+
# ... or (for this given stride) no offset would be acceptable,
1956+
# i.e. they would either result in fewer ticks than the selected
1957+
# solution, or more than the requested number of ticks.
1958+
for offset in range(0, stride):
1959+
alt_decades = range(lo + offset, hi + 1, stride)
1960+
assert len(alt_decades) < len(decades) or len(alt_decades) > numticks
19241961

19251962

19261963
def test_NullFormatter():

lib/matplotlib/ticker.py

Lines changed: 88 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2403,14 +2403,19 @@ def __call__(self):
24032403
vmin, vmax = self.axis.get_view_interval()
24042404
return self.tick_values(vmin, vmax)
24052405

2406+
def _log_b(self, x):
2407+
# Use specialized logs if possible, as they can be more accurate; e.g.
2408+
# log(.001) / log(10) = -2.999... (whether math.log or np.log) due to
2409+
# floating point error.
2410+
return (np.log10(x) if self._base == 10 else
2411+
np.log2(x) if self._base == 2 else
2412+
np.log(x) / np.log(self._base))
2413+
24062414
def tick_values(self, vmin, vmax):
2407-
if self.numticks == 'auto':
2408-
if self.axis is not None:
2409-
numticks = np.clip(self.axis.get_tick_space(), 2, 9)
2410-
else:
2411-
numticks = 9
2412-
else:
2413-
numticks = self.numticks
2415+
n_request = (
2416+
self.numticks if self.numticks != "auto" else
2417+
np.clip(self.axis.get_tick_space(), 2, 9) if self.axis is not None else
2418+
9)
24142419

24152420
b = self._base
24162421
if vmin <= 0.0:
@@ -2421,17 +2426,17 @@ def tick_values(self, vmin, vmax):
24212426
raise ValueError(
24222427
"Data has no positive values, and therefore cannot be log-scaled.")
24232428

2424-
_log.debug('vmin %s vmax %s', vmin, vmax)
2425-
24262429
if vmax < vmin:
24272430
vmin, vmax = vmax, vmin
2428-
log_vmin = math.log(vmin) / math.log(b)
2429-
log_vmax = math.log(vmax) / math.log(b)
2430-
2431-
numdec = math.floor(log_vmax) - math.ceil(log_vmin)
2431+
# Min and max exponents, float and int versions; e.g., if vmin=10^0.3,
2432+
# vmax=10^6.9, then efmin=0.3, emin=1, emax=6, efmax=6.9, n_avail=6.
2433+
efmin, efmax = self._log_b([vmin, vmax])
2434+
emin = math.ceil(efmin)
2435+
emax = math.floor(efmax)
2436+
n_avail = emax - emin + 1 # Total number of decade ticks available.
24322437

24332438
if isinstance(self._subs, str):
2434-
if numdec > 10 or b < 3:
2439+
if n_avail >= 10 or b < 3:
24352440
if self._subs == 'auto':
24362441
return np.array([]) # no minor or major ticks
24372442
else:
@@ -2442,35 +2447,79 @@ def tick_values(self, vmin, vmax):
24422447
else:
24432448
subs = self._subs
24442449

2445-
# Get decades between major ticks.
2446-
stride = (max(math.ceil(numdec / (numticks - 1)), 1)
2447-
if mpl.rcParams['_internal.classic_mode'] else
2448-
numdec // numticks + 1)
2449-
2450-
# if we have decided that the stride is as big or bigger than
2451-
# the range, clip the stride back to the available range - 1
2452-
# with a floor of 1. This prevents getting axis with only 1 tick
2453-
# visible.
2454-
if stride >= numdec:
2455-
stride = max(1, numdec - 1)
2456-
2457-
# Does subs include anything other than 1? Essentially a hack to know
2458-
# whether we're a major or a minor locator.
2459-
have_subs = len(subs) > 1 or (len(subs) == 1 and subs[0] != 1.0)
2460-
2461-
decades = np.arange(math.floor(log_vmin) - stride,
2462-
math.ceil(log_vmax) + 2 * stride, stride)
2463-
2464-
if have_subs:
2465-
if stride == 1:
2466-
ticklocs = np.concatenate(
2467-
[subs * decade_start for decade_start in b ** decades])
2450+
# Get decades between major ticks. Include an extra tick outside the
2451+
# lower and the upper limit: QuadContourSet._autolev relies on this.
2452+
if mpl.rcParams["_internal.classic_mode"]: # keep historic formulas
2453+
stride = max(math.ceil((n_avail - 1) / (n_request - 1)), 1)
2454+
decades = np.arange(emin - stride, emax + stride + 1, stride)
2455+
else:
2456+
# *Determine the actual number of ticks*: Find the largest number
2457+
# of ticks, no more than the requested number, that can actually
2458+
# be drawn (e.g., with 9 decades ticks, no stride yields 7
2459+
# ticks). For a given value of the stride *s*, there are either
2460+
# floor(n_avail/s) or ceil(n_avail/s) ticks depending on the
2461+
# offset. Pick the smallest stride such that floor(n_avail/s) <
2462+
# n_request, i.e. n_avail/s < n_request+1, then re-set n_request
2463+
# to ceil(...) if acceptable, else to floor(...) (which must then
2464+
# equal the original n_request, i.e. n_request is kept unchanged).
2465+
stride = n_avail // (n_request + 1) + 1
2466+
nr = math.ceil(n_avail / stride)
2467+
if nr <= n_request:
2468+
n_request = nr
2469+
else:
2470+
assert nr == n_request + 1
2471+
if n_request == 0: # No tick in bounds; two ticks just outside.
2472+
decades = [emin - 1, emax + 1]
2473+
stride = decades[1] - decades[0]
2474+
elif n_request == 1: # A single tick close to center.
2475+
mid = round((efmin + efmax) / 2)
2476+
stride = max(mid - (emin - 1), (emax + 1) - mid)
2477+
decades = [mid - stride, mid, mid + stride]
2478+
else:
2479+
# *Determine the stride*: Pick the largest stride that yields
2480+
# this actual n_request (e.g., with 15 decades, strides of
2481+
# 5, 6, or 7 *can* yield 3 ticks; picking a larger stride
2482+
# minimizes unticked space at the ends). First try for
2483+
# ceil(n_avail/stride) == n_request
2484+
# i.e.
2485+
# n_avail/n_request <= stride < n_avail/(n_request-1)
2486+
# else fallback to
2487+
# floor(n_avail/stride) == n_request
2488+
# i.e.
2489+
# n_avail/(n_request+1) < stride <= n_avail/n_request
2490+
# One of these cases must have an integer solution (given the
2491+
# choice of n_request above).
2492+
stride = (n_avail - 1) // (n_request - 1)
2493+
if stride < n_avail / n_request: # fallback to second case
2494+
stride = n_avail // n_request
2495+
# *Determine the offset*: For a given stride *and offset*
2496+
# (0 <= offset < stride), the actual number of ticks is
2497+
# ceil((n_avail - offset) / stride), which must be equal to
2498+
# n_request. This leads to olo <= offset < ohi, with the
2499+
# values defined below.
2500+
olo = max(n_avail - stride * n_request, 0)
2501+
ohi = min(n_avail - stride * (n_request - 1), stride)
2502+
# Try to see if we can pick an offset so that ticks are at
2503+
# integer multiples of the stride while satisfying the bounds
2504+
# above; if not, fallback to the smallest acceptable offset.
2505+
offset = (-emin) % stride
2506+
if not olo <= offset < ohi:
2507+
offset = olo
2508+
decades = range(emin + offset - stride, emax + stride + 1, stride)
2509+
2510+
# Guess whether we're a minor locator, based on whether subs include
2511+
# anything other than 1.
2512+
is_minor = len(subs) > 1 or (len(subs) == 1 and subs[0] != 1.0)
2513+
if is_minor:
2514+
if stride == 1 or n_avail <= 1:
2515+
# Minor ticks start in the decade preceding the first major tick.
2516+
ticklocs = np.concatenate([
2517+
subs * b**decade for decade in range(emin - 1, emax + 1)])
24682518
else:
24692519
ticklocs = np.array([])
24702520
else:
2471-
ticklocs = b ** decades
2521+
ticklocs = b ** np.array(decades)
24722522

2473-
_log.debug('ticklocs %r', ticklocs)
24742523
if (len(subs) > 1
24752524
and stride == 1
24762525
and ((vmin <= ticklocs) & (ticklocs <= vmax)).sum() <= 1):

0 commit comments

Comments
 (0)