Shanraq.org Shanraq.org
Как не соврать графиком: шкала, ноль и подписи
IT

Python: от данных до своей сводки Урок 34 из 56

Как не соврать графиком: шкала, ноль и подписи

Тридцать третий урок курса по Python. Те же два числа: от нуля столбик выше в 1.31 раза, с осью от восьми — в 4.86. Тот же отрезок линии: 14° на широком листе и 73° на узком. Проценты против процентных пунктов, выбор куска ряда — и график, который проверяет сам себя.

Зачем это нужно

Прошлый урок научил рисовать. Этот — про то, что нарисованное почти всегда убедительнее, чем заслуживают числа.

Соврать графиком легко, и почти никогда это не делается нарочно. Ось начали не с нуля, потому что «так лучше видно разницу». Взяли последние три месяца, потому что «остальное уже неактуально». Написали «выросло на 31 %», потому что так посчиталось. Каждый шаг кажется разумным, а вместе они дают картинку, из которой читатель выносит то, чего в данных нет.

Поэтому урок не про вкус, а про счёт: каждое искажение здесь измерено.

Сразу целиком

Файл chestno.py. Одни и те же числа, посчитанные искажения и две картинки рядом.

"""Урок 33: как не соврать графиком.

Один и тот же ряд, нарисованный честно и нечестно. Все искажения посчитаны, а
не описаны словами: во сколько раз столбик выглядит выше, чем есть, и на
сколько градусов «круче» становится линия от смены пропорций.
"""

import math

import matplotlib

matplotlib.use("Agg")

import matplotlib.pyplot as plt
from pathlib import Path

HERE = Path(__file__).resolve().parent

# Инфляция, % за год. Данные Всемирного банка.
years = [2021, 2022, 2023, 2024, 2025]
kz = [8.0, 15.0, 14.5, 8.7, 11.4]

print("== столбики: где начинается ось")
low, high = 8.7, 11.4
print("на самом деле:", high, "против", low, "— в", round(high / low, 2), "раза")
for bottom in (0.0, 8.0):
    look = (high - bottom) / (low - bottom)
    print(f"  ось от {bottom:>4}: столбик выглядит выше в {look:.2f} раза")

fig, axes = plt.subplots(1, 2, figsize=(9, 3.5))
for ax, bottom, title in ((axes[0], 0.0, "ось от нуля"), (axes[1], 8.0, "ось от восьми")):
    ax.bar(["2024", "2025"], [low, high], color=["#7f8c8d", "#b03a2e"])
    ax.set_ylim(bottom, 12)
    ax.set_title(title)
    ax.set_ylabel("инфляция, %")
fig.savefig(HERE / "stolbiki.png", dpi=120, bbox_inches="tight")
plt.close(fig)

print()
print("== линия: одни и те же числа под разным углом")
# Угол считаем честно: из данных в координаты рисунка, через размер осей.
def slope_degrees(width, height, ylow, yhigh):
    x_per_inch = (len(years) - 1) / width
    y_per_inch = (yhigh - ylow) / height
    return math.degrees(math.atan(((kz[-1] - kz[-2]) / y_per_inch) / (1 / x_per_inch)))

for size, ylim in (((8, 3), (0, 16)), ((4, 5), (8, 12))):
    angle = slope_degrees(size[0], size[1], ylim[0], ylim[1])
    print(f"  лист {size[0]}×{size[1]} дюйма, ось {ylim}: последний отрезок {angle:.1f}°")

print()
print("== проценты и процентные пункты")
print("было", low, "%, стало", high, "% — это рост на", round(high - low, 1), "процентного пункта")
print("а «на", round((high - low) / low * 100, 1), "%» — это другое утверждение о других числах")

print()
print("== какой кусок ряда показать")
print("весь ряд:", kz[0], "→", kz[-1], "| изменение", round(kz[-1] - kz[0], 1), "п.п.")
print("последние два года:", kz[-2], "→", kz[-1], "| изменение", round(kz[-1] - kz[-2], 1), "п.п.")
print("оба утверждения верны, и выбирают из них до того, как рисуют")

Выводит:

== столбики: где начинается ось
на самом деле: 11.4 против 8.7 — в 1.31 раза
  ось от  0.0: столбик выглядит выше в 1.31 раза
  ось от  8.0: столбик выглядит выше в 4.86 раза

== линия: одни и те же числа под разным углом
  лист 8×3 дюйма, ось (0, 16): последний отрезок 14.2°
  лист 4×5 дюйма, ось (8, 12): последний отрезок 73.5°

== проценты и процентные пункты
было 8.7 %, стало 11.4 % — это рост на 2.7 процентного пункта
а «на 31.0 %» — это другое утверждение о других числах

== какой кусок ряда показать
весь ряд: 8.0 → 11.4 | изменение 3.4 п.п.
последние два года: 8.7 → 11.4 | изменение 2.7 п.п.
оба утверждения верны, и выбирают из них до того, как рисуют

Рядом появляется stolbiki.png — те самые два графика: слева честный, справа нет.

Разбор

Столбик меряют длиной, поэтому он начинается с нуля

Столбик кодирует значение длиной. Читатель сравнивает не верхние края, а высоты целиком: «этот вдвое выше того». Если ось начать не с нуля, длина перестаёт соответствовать числу, а глаз об этом не знает.

Числа из вывода: 8.7 и 11.4 отличаются в 1.31 раза. На оси от нуля так и выглядит. На оси от восьми правый столбик выглядит выше в 4.86 раза. Разница между честной картинкой и нечестной здесь — не «немного», а почти вчетверо.

Правило простое и без исключений: у столбиков ось начинается с нуля. Если разница при этом не видна — это и есть ответ: разницы мало. Хотите показать её крупно — покажите не столбики, а сами числа или разность.

У линии по-другому, но не бесплатно

Линия кодирует значение положением точки, а не длиной, поэтому ей ноль не обязателен: температуру от нуля никто не рисует. Но обрезанная ось у линии всё равно меняет впечатление, и прятать это не надо: если ось не от нуля — так и подпишите.

Пропорции листа — тоже утверждение

Самое незаметное искажение. В выводе один и тот же последний отрезок ряда:

  • лист 8×3 дюйма, ось от нуля до 16 — наклон 14.2°, ряд выглядит спокойным;
  • лист 4×5 дюймов, ось от 8 до 12 — наклон 73.5°, тот же ряд выглядит обвалом.

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

Проценты и процентные пункты

Инфляция была 8.7 %, стала 11.4 %. Правильных фраз две, и они разные:

  • выросла на 2.7 процентного пункта — это разность;
  • сама величина выросла на 31 % — это относительное изменение.

Написать «выросла на 31 %», имея в виду первое, — обычная ошибка, и она делает утверждение в одиннадцать раз громче. Для всего, что само измеряется в процентах — инфляция, ставки, доли, — в подписи должно стоять «п.п.» или «%» осознанно.

Какой кусок ряда показать

Весь ряд: с 8.0 до 11.4, изменение 3.4 п.п. Последние два года: с 8.7 до 11.4, изменение 2.7 п.п. Оба утверждения верны, но рассказывают разное, а третий вариант — 2022–2024 — покажет падение.

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

Что должно быть на каждом графике

Пять вещей, без которых картинку нельзя отправлять:

  1. Заголовок — что показано.
  2. Подпись оси Y с единицами — «%», «₸», «штук». Без единиц число не значит ничего.
  3. Подпись оси X — что по горизонтали, и какой это период.
  4. Источник и дата выгрузки — прямо на картинке: её перешлют без страницы, на которой она лежала.
  5. Сколько наблюденийN = 5. График по трём точкам и по трёмстам выглядят одинаково, а стоят разного.

Шестое — по ситуации: если ось не от нуля, если ряд сглажен, если часть данных выброшена, это пишут рядом. Сглаженный ряд, как урок про время показал, вообще стоит показывать вместе с настоящим.

Карта урока

Карта урока: ноль, пропорции и подписи

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Почему у столбиков ось обязана начинаться с нуля, а у линии — нет?
  2. Чем «выросло на 2.7 процентного пункта» отличается от «выросло на 31 %»?
  3. Что меняется в графике, если не трогать ни одного числа, а изменить пропорции листа?

Разминка

Три коротких шага перед заданием: предсказать, дописать, починить. Ответы — в конце урока, но сначала ответьте сами.

1. Предскажите. Что напечатает эта программа?

low, high = 8.7, 11.4
for bottom in (0.0, 7.0, 8.5):
    look = (high - bottom) / (low - bottom)
    print(f"ось от {bottom}: выше в {look:.2f} раза (на самом деле в {high / low:.2f})")

2. Заполните пропуск. Вместо ... поставьте границы так, чтобы столбики были честными.

# столбик меряют длиной
import matplotlib

matplotlib.use("Agg")

import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(4, 3))
ax.bar(["2024", "2025"], [8.7, 11.4], color="#b03a2e")
ax.set_ylim(...)
ax.set_ylabel("инфляция, %")
print("низ оси:", ax.get_ylim()[0], "| подпись:", ax.get_ylabel())
plt.close(fig)

3. Почините. Программа считает относительное изменение, а называет его процентными пунктами.

# 2.7 и 31 — разные числа и разные утверждения
low, high = 8.7, 11.4
change = (high - low) / low * 100
print(f"инфляция выросла на {change:.1f} процентного пункта")

Задание

Обязательное. Нарисуйте честный график инфляции по пяти годам — столбиками, от нуля, с заголовком, подписями обеих осей, числом наблюдений в заголовке и источником прямо на картинке. Сохраните в файл. Затем спросите у готовых осей, выполнено ли каждое правило, и напечатайте проверку: начинается ли ось с нуля, есть ли единицы в подписи, назван ли объём данных, стоит ли источник. В конце — что этот график говорит: изменение за пять лет, максимум и минимум с годами.

Ожидаемый вывод:

проверка графика:
  ось Y от нуля: True
  подпись оси Y: 'инфляция, % за год'
  в подписи есть единицы: True
  заголовок: 'Инфляция в Казахстане, 2021—2025 (N = 5)'
  число точек названо: True
  источник на картинке: True
  столбиков: 5 | файл: True

что говорит этот график:
  за пять лет: 8.0 → 11.4 | изменение 3.4 п.п.
  максимум: 15.0 в 2022 | минимум: 8.0 в 2021

Готово, когда: вывод совпадает построчно; проверка спрашивает сам объект (ax.get_ylim(), ax.get_ylabel(), fig.texts), а не повторяет то, что вы написали выше; источник добавлен через fig.text, а не в заголовок; файл сохранён после всего рисования.

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

По желанию.

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

Куда это встанет в проекте

Шага нет: сводка рисует одну картинку, и правила этого урока в неё уже встроены — ось от нуля там не нужна, потому что это линия, а подписи и легенда стоят с прошлого шага.

Но один долг урок закрывает. В картинке сводки не было источника и даты, и её нельзя было отправить отдельно от отчёта: получатель не знал, откуда числа. В следующем шаге, где отчёт станет страницей, источник появится и на картинке, и под таблицей — одной строкой, взятой из того же места, что и данные.

Ответы

Показать ответы

На вопросы

  1. Потому что столбик кодирует значение длиной, и глаз сравнивает длины целиком. Обрезали ось — длина перестала соответствовать числу: 1.31 раза превращается в 4.86. Линия кодирует значение положением точки, а не длиной, поэтому ноль ей не обязателен — но обрезанную ось всё равно подписывают.
  2. Первое — разность двух величин, выраженных в процентах: 11.4 − 8.7. Второе — относительное изменение: на сколько процентов выросла сама величина. Для инфляции это 2.7 и 31.0 — разница в одиннадцать раз, и перепутать их значит сделать утверждение громче, чем данные позволяют.
  3. Наклон линий, а с ним и впечатление. Один и тот же отрезок — 14.2° на широком листе с осью от нуля и 73.5° на узком с обрезанной осью. Ни одно число не изменилось, а «спокойный ряд» стал «обвалом».

К разминке

  1. Чем выше начинается ось, тем сильнее преувеличение: от нуля — настоящие 1.31 раза, от 8.5 — четырнадцать с половиной.
ось от 0.0: выше в 1.31 раза (на самом деле в 1.31)
ось от 7.0: выше в 2.59 раза (на самом деле в 1.31)
ось от 8.5: выше в 14.50 раза (на самом деле в 1.31)
  1. ax.set_ylim(0, 16). Ноль внизу обязателен, верх выбирают так, чтобы самый высокий столбик не упирался в край.
import matplotlib

matplotlib.use("Agg")

import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(4, 3))
ax.bar(["2024", "2025"], [8.7, 11.4], color="#b03a2e")
ax.set_ylim(0, 16)
ax.set_ylabel("инфляция, %")
print("низ оси:", ax.get_ylim()[0], "| подпись:", ax.get_ylabel())
plt.close(fig)
низ оси: 0.0 | подпись: инфляция, %
  1. Процентные пункты — это разность: high - low. Относительное изменение считают отдельно и называют своим именем.
low, high = 8.7, 11.4
points = high - low
percent = (high - low) / low * 100
print(f"инфляция выросла на {points:.1f} процентного пункта")
print(f"то есть сама величина инфляции стала больше на {percent:.1f} %")
инфляция выросла на 2.7 процентного пункта
то есть сама величина инфляции стала больше на 31.0 %

К заданию

Проверка спрашивает сам график, а не программиста. Это важно: подпись можно поставить в одном месте и забыть в другом, а ax.get_ylabel() отвечает за то, что действительно нарисовано. Такую проверку не жалко оставить в коде отчёта навсегда — она стоит четыре строки и ловит забытую подпись раньше читателя.

Источник ставится через fig.text, а не в заголовок: заголовок отвечает на вопрос «что показано», а источник — «откуда числа», и это разные вопросы. На листе он живёт внизу слева, мелким и серым.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.