2025, Nov 01 10:46

SecondaryAxis в Matplotlib: синхронизация делений и обратные функции

Пошагово разбираем SecondaryAxis в Matplotlib: чем он отличается от twinx/twiny, как задать взаимно обратные функции и выровнять деления 1:1. Пример кода.

Вторичные оси в Matplotlib кажутся обманчиво простыми: передаёте пару функций — и появляется новая ось с преобразованными значениями. Но как только вы хотите, чтобы деления на вторичной оси совпадали один к одному с делениями основной, легко запутаться, что именно преобразуется и как этим управлять. Ниже — разбор, который разводит понятия Axes и axis, объясняет, почему twinx/twiny здесь не подходят, и показывает, как принудительно синхронизировать деления вторичной оси с основной с помощью явного контроля.

Воспроизведение примера

Ниже приведён пример с залитой картой уровней и подключёнными вторичными осями, соответствующими преобразованиям Y = 1/y и X = 3x. Задача — выровнять вторичные метки по делениям основной оси и формировать их подписи как функции от первичных значений.

import matplotlib.pyplot as plt
import numpy as np

# визуализируемая функция
def plane_fn(u, v):
    return (1 - (u ** 2 + v ** 3)) * np.exp(-(u ** 2 + v ** 2) / 2)

v_hi = 2.5
v_lo = 1
u_hi = v_hi
u_lo = v_lo

u_vals = np.arange(v_lo, v_hi, 0.01)
v_vals = np.arange(u_lo, u_hi, 0.01)
UU, VV = np.meshgrid(u_vals, v_vals)
WW = plane_fn(UU, VV)

fig1, ax1 = plt.subplots()

cf = ax1.contourf(UU, VV, WW)

x_ticks = np.linspace(u_lo, u_hi, 9)
x_tick_lbls = [fr'{i:.1f}' for i in x_ticks]
ax1.set_xticks(ticks=x_ticks, labels=x_tick_lbls)
ax1.set_xlabel('x')

y_ticks = np.linspace(v_lo, v_hi, 9)
y_tick_lbls = [fr'{i:.1f}' for i in y_ticks]
ax1.set_yticks(ticks=y_ticks, labels=y_tick_lbls)
ax1.set_ylabel('y')

cbar = fig1.colorbar(cf, ax=ax1, location='right')
cbar.ax.set_ylabel(r'$z=f(x,y)$')

_ = y_ticks ** 2  # заглушка-вычисление, оставлено из исходной настройки

fig1.subplots_adjust(left=0.20)

def y_map(y):
    return 1 / y

# вторичная ось Y; здесь та же функция передана как собственная обратная
sec_y = ax1.secondary_yaxis('left', functions=(y_map, y_map))
sec_y.spines['left'].set_position(('outward', 40))
sec_y.set_ylabel(r'Y=1/y')
sec_y.yaxis.set_inverted(True)

fig1.subplots_adjust(bottom=0.27)

def x_map(x):
    return 3 * x

# вторичная ось X; неверная обратная функция намеренно, для демонстрации проблемы
sec_x = ax1.secondary_xaxis('bottom', functions=(x_map, x_map))
sec_x.spines['bottom'].set_position(('outward', 30))
sec_x.set_xlabel(r'X=3$\times$ x')

ax1.grid(visible=False)
ax1.set_title(r'$z=(1-x^2+y^3) e^{-(x^2+y^2)/2}$')
fig1.tight_layout()
plt.show()

В чём настоящая проблема?

Первая тонкость — терминология. В Matplotlib объект Axes (на нём вы строите графики, как ax1) отличается от оси axis (шкала по x/y с делениями и подписями). SecondaryAxis добавляет трансформированный вид уже существующей оси: новые деления и подписи вычисляются через пару функций. Он не создаёт новый Axes и не даёт вторую плоскость для рисования данных. Напротив, twinx/twiny создают совершенно новый объект Axes, наложенный на исходный, с общей одной координатой (x для twinx, y для twiny) и независимым авто-масштабированием по другой. Это удобно для несвязанных единиц измерения, но не для детерминированного соответствия одной и той же величины в разных шкалах.

Иными словами, twinx и twiny не про эту задачу. Вам не нужна ещё одна Axes — нужна вторичная ось, чьи деления являются преобразованием делений основной и визуально с ними совпадают.

Вторая тонкость — управление. По умолчанию SecondaryAxis сам вычисляет положения делений по заданным функциям преобразования и текущим границам вида. Если требуется строгое совпадение один к одному, задавайте деления вторичной оси явно, преобразуя позиции делений основной. Наконец, при создании SecondaryAxis Matplotlib ожидает пару взаимно обратных функций: прямую и её настоящую обратную. Передача не-инверсии иногда выглядит рабочей, но это не гарантия и может приводить к сбоям или странному поведению.

Решение: управляйте делениями вторичной оси сами

Проще всего добиться идеального совпадения так: возьмите текущие деления основной оси, пропустите их через прямую функцию и назначьте результат делениями вторичной. Тогда расстояния и позиции на экране будут совпадать точно. И обязательно указывайте действительно взаимно обратную пару функций там, где это требуется.

import matplotlib.pyplot as plt
import numpy as np

def plane_fn(u, v):
    return (1 - (u ** 2 + v ** 3)) * np.exp(-(u ** 2 + v ** 2) / 2)

v_hi = 2.5
v_lo = 1
u_hi = v_hi
u_lo = v_lo

u_vals = np.arange(v_lo, v_hi, 0.01)
v_vals = np.arange(u_lo, u_hi, 0.01)
UU, VV = np.meshgrid(u_vals, v_vals)
WW = plane_fn(UU, VV)

fig1, ax1 = plt.subplots()

cf = ax1.contourf(UU, VV, WW)

x_ticks = np.linspace(u_lo, u_hi, 9)
x_tick_lbls = [fr'{i:.1f}' for i in x_ticks]
ax1.set_xticks(ticks=x_ticks, labels=x_tick_lbls)
ax1.set_xlabel('x')

y_ticks = np.linspace(v_lo, v_hi, 9)
y_tick_lbls = [fr'{i:.1f}' for i in y_ticks]
ax1.set_yticks(ticks=y_ticks, labels=y_tick_lbls)
ax1.set_ylabel('y')

cbar = fig1.colorbar(cf, ax=ax1, location='right')
cbar.ax.set_ylabel(r'$z=f(x,y)$')

_ = y_ticks ** 2

fig1.subplots_adjust(left=0.20)

def y_map(y):
    return 1 / y

# y_map является собственной обратной на положительных y
sec_y = ax1.secondary_yaxis('left', functions=(y_map, y_map))
sec_y.spines['left'].set_position(('outward', 40))
sec_y.set_ylabel(r'Y=1/y')
sec_y.yaxis.set_inverted(True)

# выравниваем деления вторичной оси Y с основной через прямое преобразование
sec_y.set_yticks(y_map(ax1.get_yticks()))

fig1.subplots_adjust(bottom=0.27)

def x_map(x):
    return 3 * x

# задаём истинную обратную функцию для преобразования x
sec_x = ax1.secondary_xaxis('bottom', functions=(x_map, lambda t: t / 3))
sec_x.spines['bottom'].set_position(('outward', 30))
sec_x.set_xlabel(r'X=3$\times$ x')

# выравниваем деления вторичной оси X с основной через прямое преобразование
sec_x.set_xticks(x_map(ax1.get_xticks()))

ax1.grid(visible=False)
ax1.set_title(r'$z=(1-x^2+y^3) e^{-(x^2+y^2)/2}$')
fig1.tight_layout()
plt.show()

Такой подход обеспечивает линейное расположение делений во вторичном (преобразованном) пространстве и их точное совпадение с позициями основных делений на холсте.

Важные детали

Пары функций должны быть действительно взаимно обратными. Для преобразования по y функция y → 1/y является собственной обратной (на положительной области). Для x отображение x → 3x должно идти в паре с t → t/3. Иногда с «неправильной» обратной картинка кажется правдоподобной, но это не гарантировано. Документация требует взаимно обратную пару, и отступление от этого — зона неопределённого поведения.

Подписи могут выглядеть «неточно», если вы округляете их независимо от реальных координат делений. Например, когда деления создаются через linspace, а подписи форматируются до одного знака после запятой, подпись может немного расходиться с фактическим положением деления. После преобразования на вторичной оси это становится заметно. Принять такое округление, подобрать иную стратегию делений или повысить точность форматирования — ваш выбор; эффект чисто визуальный, но его видно.

А если преобразование сложное?

Если значения вторичной оси задаются сложной пользовательской функцией и аналитическую обратную найти трудно, контракт SecondaryAxis всё равно предполагает наличие обратной. На практике пользуются подходом из галереи: применяют интерполяцию numpy; в ссылочном решении применялась идея с использованием np.interp из четвёртого примера в галерее Matplotlib по вторичной оси. Отдельный вопрос — что будет без точной обратной: согласно интерфейсу, вторая функция должна быть обратной к первой.

Почему это важно понимать

Правильная настройка аккуратно разделяет задачи. SecondaryAxis — для альтернативного представления одной и той же координаты с детерминированным преобразованием, когда нужны только деления и подписи. twinx/twiny — для наложения нескольких наборов данных с разными единицами или диапазонами на общую ось. Использование подходящего инструмента упрощает компоновку, избавляет от хрупких взаимодействий авто-масштабирования и делает преобразования явными и предсказуемыми.

Итоги и практические советы

Если нужна вторичная шкала как функция от основной оси — используйте SecondaryAxis. Всегда передавайте корректную пару прямой/обратной функций. Чтобы гарантировать совпадение, вычисляйте деления вторичной оси как преобразование позиций делений основной и задавайте их явно. Учитывайте округление подписей: отформатированные строки могут расходиться с реальными координатами делений и создавать впечатление «неточных» меток на вторичной оси. Для сложных преобразований, где аналитическую обратную получить неудобно, рабочий путь — адаптировать интерполяционный приём из галереи Matplotlib по вторичным осям.

Статья основана на вопросе с StackOverflow от ishan_ae и ответе chrslg.