2025, Dec 04 06:02

Почему set_yticks на логарифмической оси Y в Matplotlib меняет границы и как это исправить

Разбираем, почему set_yticks на логарифмической оси Y в Matplotlib меняет пределы ylim, и показываем надежный порядок действий: шкала, метки, затем границы.

При работе с Matplotlib и логарифмической осью Y безобидный на вид вызов установки конкретных положений меток может неожиданно изменить границы по оси Y. Такое чаще всего случается, когда вы пытаетесь переиспользовать метки между осями или унифицировать их размещение на разных графиках. Суть проблемы — в порядке применения свойств осей: set_yticks может пересчитать границы, тогда как set_ylim не пересчитывает метки.

Минимальный пример, демонстрирующий проблему

Ниже — простой столбчатый график с логарифмической шкалой по оси Y. Даже если вы фактически не меняете метки, одно лишь обращение к ним способно спровоцировать изменение и меток, и пределов ylim.

import pandas as pd
import matplotlib.pyplot as plt

plt.close('all')
chart_axis = pd.Series([600, 1e3, 1e4], index=['A', 'B', 'C']).plot.bar()
plt.yscale('log')

# Этого достаточно, чтобы изменились ylim и основные метки по оси Y
if False:
    chart_axis.set_yticks(chart_axis.get_yticks(minor=False))

Такое поведение наблюдалось в Spyder 6.0.5 (conda), Python 3.9.18 64-bit, Qt 5.15.2, PyQt5 5.15.10 на Windows 10, но первопричина не зависит от окружения. Дело в том, как Matplotlib применяет настройки осей в этой ситуации.

Что на самом деле происходит

Важен порядок, в котором вы задаёте свойства осей. Вызов set_ylim не сбрасывает положения меток. Напротив, вызов set_yticks сбрасывает границы по оси Y. На логарифмической шкале это особенно заметно, потому что локатор меток и масштабирование подбирают «красивые» степени десяти, и при применении меток ось может заново вычислить свои границы.

Именно поэтому схема «получить метки — установить метки» приводит к скачку границ, даже если вы передаёте те же значения. А вот установка границ после меток сохраняет нужные пределы.

Надёжное решение: задавайте пределы последними

Если вам нужны одновременно явные метки и явные границы на логарифмической оси Y, сначала задайте шкалу, затем метки, и только потом границы. Последовательность ниже показывает разницу между двумя порядками действий.

import matplotlib.pyplot as plt

fig, (left_ax, right_ax) = plt.subplots(1, 2)

# Первый подграфик: сначала метки, затем границы (границы сохраняются)
left_ax.plot()
left_ax.set_yscale('log')
left_ax.set_xticks((-1, 0, 1))
left_ax.set_yticks([1.0e01, 1.0e02, 1.0e03, 1.0e04, 1.0e05, 1.0e06])
left_ax.set_ylim((100, 12000))
print(left_ax.get_yticks())  # [1.e+01 1.e+02 1.e+03 1.e+04 1.e+05 1.e+06]

# Второй подграфик: сначала границы, затем метки (set_yticks сбрасывает границы)
right_ax.plot()
right_ax.set_yscale('log')
right_ax.set_xticks((-1, 0, 1))
right_ax.set_ylim((100, 12000))
right_ax.set_yticks([1.0e01, 1.0e02, 1.0e03, 1.0e04, 1.0e05, 1.0e06])
print(right_ax.get_ylim())  # (np.float64(10.0), np.float64(1000000.0))

plt.show()

Если в уже существующем коде необходимо вызывать set_yticks и при этом важно сохранить текущие границы, можно поступить так: перед set_yticks закэшировать границы через ylim = plt.ylim(), а затем восстановить их plt.ylim(ylim). Это работает, потому что изменение границ инициирует именно set_yticks; восстановление после вызова принудительно возвращает нужный диапазон.

О параметре sharey и почему он не решает эту задачу

Иногда советуют использовать plt.subplots(..., sharey=True). Этот вариант уместен только тогда, когда несколько подграфиков в одной фигуре действительно разделяют одну и ту же шкалу Y. Он не устраняет базовое поведение set_yticks по пересчёту границ и становится вредным, если оси концептуально различны. Рассмотрим эксперимент с двумя соседними коробчатыми диаграммами: на одной оси — логарифмическая шкала для исходных данных, а на другой — значения после преобразования log10, где для усов берутся иные распределения.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.stats import weibull_min

# Создадим 3 группы линейных данных по 1000 точек
bin_labels = ['Alpha', 'Brave', 'Charlie']
scale_factors = [1, 10, 100]
sample_size = 1000
shape_param = 0.5

frame = pd.concat([
    pd.DataFrame({
        'Bin': [bin_labels[i]] * sample_size,
        'Linear': weibull_min(c=shape_param, scale=scale_factors[i]).rvs(size=sample_size)
    })
    for i in range(3)
])

# Сначала коробчатая диаграмма для линейных данных, затем логарифмическое масштабирование оси Y
plt.close('all')
fig, (axis_a, axis_b) = plt.subplots(1, 2, layout='constrained', sharey=True)
frame.boxplot('Linear', by='Bin', ax=axis_a)
plt.yscale('log')

# Коробчатая диаграмма для данных, преобразованных через log10
frame['Log10'] = np.log10(frame.Linear)
frame.boxplot('Log10', by='Bin', ax=axis_b)

Здесь коробки и усы второго графика считаются по преобразованным данным и не сопоставляются корректно с исходной (непреобразованной) шкалой. В таких сценариях sharey=True не нужен вовсе. В более типичном процессе вы можете построить график по линейным данным, временно включить логарифмическую шкалу, чтобы получить метки по оси Y, затем очистить и построить заново по логарифмированным данным — чтобы получить корректные усы. В этой последовательности нет нескольких совместно используемых подграфиков, поэтому sharey неприменим.

Почему эта тонкость важна

Программная унификация осей — обычная задача в конвейерах данных и дашбордах. Копирование меток с одной оси на другую или фиксация положений меток ради читабельности не должны незаметно перескалировать график. Понимание того, что set_yticks на логарифмической оси инициирует пересчёт границ, помогает избежать трудноуловимых несоответствий, особенно при работе с композициями из нескольких осей или при постобработке графиков, созданных более высокоуровневыми обёртками.

Выводы

На логарифмических осях Y порядок действий критичен. Если вам нужны явные метки и явные границы, сначала задайте шкалу, затем метки, и только потом границы. Если set_yticks необходимо вызвать уже после установки границ, сохраните и восстановите ylim вокруг этого вызова. Не используйте sharey, если оба подграфика не отражают одну и ту же шкалу и смысл — это не исправит сброс границ и может ввести в заблуждение на несоответствующих осях.