{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Длинный и широкий формат\n",
    "\n",
    "Одни и те же данные в двух формах: широкой — для чтения глазами, длинной — для вычислений. melt переводит широкую таблицу в длинную, pivot — обратно; чем pivot отличается от pivot_table.\n",
    "\n",
    "Курс pandas · урок `pd-melt-pivot` · [урок на сайте](https://ds-edu-trainer.vercel.app/courses/pandas/melt-pivot).\n",
    "\n",
    "Выполняйте ячейки по порядку (Shift+Enter), начиная с первой — она готовит урок. После каждого упражнения идёт\n",
    "ячейка проверки: она печатает ✓ / ✗ и итог."
   ],
   "id": "intro"
  },
  {
   "cell_type": "code",
   "metadata": {
    "cellView": "form",
    "jupyter": {
     "source_hidden": true
    }
   },
   "source": [
    "# @title Подготовка урока — выполните эту ячейку первой\n",
    "# Проверяет версии пакетов, записывает файлы данных в папку data/ и определяет _check — проверку упражнений.\n",
    "import base64, os, zlib\n",
    "\n",
    "for _name, _min in {\"numpy\":\"2.0\",\"pandas\":\"3.0\"}.items():\n",
    "    try:\n",
    "        _have = __import__(_name).__version__\n",
    "    except ImportError:\n",
    "        print(f\"Нет пакета {_name}: выполните %pip install {_name} и перезапустите ядро\")\n",
    "        continue\n",
    "    if tuple(int(x) for x in _have.split(\".\")[:2]) < tuple(int(x) for x in _min.split(\".\")):\n",
    "        print(f\"Урок написан для {_name} {_min} и новее, у вас {_have}: выполните %pip install -U {_name} и перезапустите ядро\")\n",
    "\n",
    "_DATA = {\n",
    "    \"scores.csv\": \"\"\"\n",
    "eNo9kV1uGzEMhN91lnkgKfHvHDpAETSBvUDtBF73IbcPSBcFFgKWGn0cDs/n3/eP+/PX8Y7b2/OKr+v3efw+8fX4vDzebrfjfsH1\n",
    "OJ+fj2983C9/jvM6NjEssLLOEASPTQInmFXJHeFj04QxNOACIziNTQtuMGnVgs+xSeEBz9IGwXNsMpjCHK4tL7xDpXGG5ViFD+iE\n",
    "LahWB19jU3ZJoY5cWHNsJuiCJdaEM0zHZi620T9hqwQxkRPp1ZGJxuZZLkuZ9WmOzQs26413V6uXiiiLDaf2ytbRtAUjqIzN3qzs\n",
    "iRQWY3Mgub1bD1nGsvAVlTSexhaqACtj7Q45tjCCkZ1hZLNE6nJFsxmuY8vsn7Zm6/Wwww9uVrxKCuUKVBma0GIZ/DU5YUlvTWqf\n",
    "XZWaqZKWvtcOx72TkKz9MVEFEtS1Sb2Q/yeNH8ySg9M=\n",
    "\"\"\",\n",
    "    \"grades.csv\": \"\"\"\n",
    "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\n",
    "Rhy7JhOhEHbFXeVLvKlK4D34Hz8sCoM=\n",
    "\"\"\",\n",
    "}\n",
    "os.makedirs(\"data\", exist_ok=True)\n",
    "for _name, _packed in _DATA.items():\n",
    "    with open(os.path.join(\"data\", _name), \"wb\") as _f:\n",
    "        _f.write(zlib.decompress(base64.b64decode(_packed)))\n",
    "print(\"Файлы данных:\", \", \".join(sorted(_DATA)))\n",
    "\n",
    "# ─── Проверка упражнений: тот же раннер, что у задач на сайте ───\n",
    "# ─── Запуск тестов ───────────────────────────────────────────────────────────\n",
    "\n",
    "_TIMEOUT = 2.0  # секунд на тест; отдельный тест может задать свой: test_x.timeout = 10\n",
    "\n",
    "\n",
    "def _call_with_timeout(target, seconds: float) -> bool:\n",
    "    \"\"\"Запускает target() в отдельном потоке. Возвращает False, если за seconds он не завершился.\"\"\"\n",
    "    import sys\n",
    "    import threading\n",
    "\n",
    "    if sys.platform == \"emscripten\":  # Python в браузере: потоков нет, таймаут обеспечивает страница\n",
    "        target()\n",
    "        return True\n",
    "    # Большой стек нужен, чтобы глубокая рекурсия давала RecursionError, а не аварийное завершение Python.\n",
    "    for megabytes in (256, 64, 0):\n",
    "        try:\n",
    "            threading.stack_size(megabytes * 1024 * 1024)\n",
    "            worker = threading.Thread(target=target, daemon=True)\n",
    "            worker.start()\n",
    "        except (ValueError, RuntimeError, MemoryError):\n",
    "            continue\n",
    "        worker.join(seconds)\n",
    "        return not worker.is_alive()\n",
    "    target()  # потоки недоступны — запускаем без таймаута\n",
    "    return True\n",
    "\n",
    "\n",
    "def _test_info(fn) -> dict:\n",
    "    \"\"\"Описание теста, его лимит времени и сообщение на случай зависания.\"\"\"\n",
    "    limit = getattr(fn, \"timeout\", _TIMEOUT)\n",
    "    hint = getattr(fn, \"timeout_hint\", \"\")\n",
    "    message = (\n",
    "        f\"тест не завершился за {limit:g} с — возможно, бесконечный цикл или рекурсия: \"\n",
    "        \"проверьте, что границы/аргументы изменяются на каждом шаге\" + (f\". {hint}\" if hint else \"\")\n",
    "    )\n",
    "    return {\"title\": (fn.__doc__ or fn.__name__).strip(), \"timeout\": limit, \"timeout_message\": message}\n",
    "\n",
    "\n",
    "def _error_result(title: str, error: BaseException, user_file: str | None) -> dict:\n",
    "    \"\"\"Результат упавшего теста: статус, сообщение, тип исключения и строка в коде решения.\"\"\"\n",
    "    line = None\n",
    "    if user_file:  # самый глубокий кадр в коде решения; ошибка внутри самих тестов — без номера строки\n",
    "        tb = error.__traceback__\n",
    "        while tb is not None:\n",
    "            if tb.tb_frame.f_code.co_filename == user_file:\n",
    "                line = tb.tb_lineno\n",
    "            tb = tb.tb_next\n",
    "    status, message = \"error\", f\"{type(error).__name__}: {error}\"\n",
    "    if isinstance(error, NotImplementedError):\n",
    "        status, message, line = \"not_written\", \"функция ещё не написана\", None\n",
    "    elif isinstance(error, AssertionError):\n",
    "        status, message = \"failed\", str(error) or \"проверка не прошла\"\n",
    "    elif isinstance(error, RecursionError):\n",
    "        message = \"RecursionError: слишком глубокая рекурсия — проверьте базовый случай и то, что задача уменьшается\"\n",
    "    elif isinstance(error, MemoryError) or (isinstance(error, OverflowError) and \"index-sized\" in str(error)):\n",
    "        message = f\"{type(error).__name__}: не хватило памяти — возможно, создаётся слишком большая структура данных\"\n",
    "    return {\"title\": title, \"status\": status, \"message\": message, \"error_type\": type(error).__name__, \"line\": line}\n",
    "\n",
    "\n",
    "def _run_tests(verbose: bool = True, on_start=None, on_result=None, user_file: str | None = None) -> list[dict]:\n",
    "    \"\"\"Запускает тесты задачи (_TESTS — функции из tests.py) по порядку, каждый — с таймаутом.\n",
    "\n",
    "    Возвращает список результатов: {\"title\", \"status\", \"message\", \"error_type\", \"line\"}, где status —\n",
    "    passed · failed · error · not_written · timeout · not_run. Если тест завис, при verbose=True печатает\n",
    "    итог и завершает процесс: зависший поток остановить нельзя. on_start(i) и on_result(i, результат)\n",
    "    вызываются по мере выполнения; user_file — имя файла с кодом решения для номеров строк в ошибках.\n",
    "    \"\"\"\n",
    "    import os\n",
    "    import sys\n",
    "\n",
    "    if verbose:\n",
    "        try:  # ✓ и кириллица в консоли Windows со старой кодировкой\n",
    "            sys.stdout.reconfigure(encoding=\"utf-8\")\n",
    "        except Exception:  # noqa: BLE001 — reconfigure может не быть (перенаправленный вывод, браузер)\n",
    "            pass\n",
    "\n",
    "    tests = list(_TESTS)\n",
    "    results: list[dict] = []\n",
    "    not_written = hung = False\n",
    "    for index, fn in enumerate(tests):\n",
    "        info = _test_info(fn)\n",
    "        outcome: dict = {}\n",
    "\n",
    "        def target(fn=fn, outcome=outcome):\n",
    "            try:\n",
    "                fn()\n",
    "            except BaseException as e:\n",
    "                outcome[\"error\"] = e\n",
    "\n",
    "        if on_start:\n",
    "            on_start(index)\n",
    "        if not _call_with_timeout(target, info[\"timeout\"]):\n",
    "            results.append({\"title\": info[\"title\"], \"status\": \"timeout\", \"message\": info[\"timeout_message\"], \"error_type\": None, \"line\": None})\n",
    "            for other in tests[index + 1:]:\n",
    "                results.append({\"title\": _test_info(other)[\"title\"], \"status\": \"not_run\", \"message\": \"не запускался: предыдущий тест завис\", \"error_type\": None, \"line\": None})\n",
    "            hung = True\n",
    "            break\n",
    "        error = outcome.get(\"error\")\n",
    "        if error is None:\n",
    "            result = {\"title\": info[\"title\"], \"status\": \"passed\", \"message\": \"\", \"error_type\": None, \"line\": None}\n",
    "        else:\n",
    "            result = _error_result(info[\"title\"], error, user_file)\n",
    "            not_written = not_written or result[\"status\"] == \"not_written\"\n",
    "        results.append(result)\n",
    "        if on_result:\n",
    "            on_result(index, result)\n",
    "\n",
    "    if not_written:  # пока функция не написана, «даром» пройденные тесты не засчитываются\n",
    "        for r in results:\n",
    "            if r[\"status\"] == \"passed\":\n",
    "                r.update(status=\"not_written\", message=\"не засчитан, пока функция не написана\")\n",
    "    for r in results:\n",
    "        if len(r[\"message\"]) > 300:\n",
    "            r[\"message\"] = r[\"message\"][:300] + \"…\"\n",
    "\n",
    "    if verbose:\n",
    "        if not_written:\n",
    "            print(\"Функция ещё не написана — замените raise NotImplementedError своим кодом.\\n\")\n",
    "        for r in results:\n",
    "            mark = {\"passed\": \"✓\", \"not_run\": \"–\"}.get(r[\"status\"], \"✗\")\n",
    "            print(f\"{mark} {r['title']}\")\n",
    "            if r[\"message\"]:\n",
    "                print(f\"    {r['message']}\")\n",
    "        passed = sum(r[\"status\"] == \"passed\" for r in results)\n",
    "        print(\"─\" * 40)\n",
    "        print(f\"Прошло {passed} из {len(results)}\" + (\" — всё верно!\" if passed == len(results) else \"\"))\n",
    "        if hung:\n",
    "            print(\"Выполнение остановлено: один из тестов завис.\")\n",
    "            sys.stdout.flush()\n",
    "            os._exit(1)\n",
    "    return results\n",
    "\n",
    "\n",
    "def _check(*tests, names=()):\n",
    "    \"\"\"Запускает проверки упражнения и печатает ✓ / ✗ и итог — как на сайте.\"\"\"\n",
    "    for name in names:\n",
    "        if \".\" in name:  # «Класс.метод» из заготовки: класс должен быть объявлен\n",
    "            if not isinstance(globals().get(name.split(\".\")[0]), type):\n",
    "                print(f\"✗ класс {name.split('.')[0]} не объявлен — выполните ячейку упражнения\")\n",
    "                return\n",
    "            continue\n",
    "        if name not in globals():\n",
    "            print(f\"✗ переменная {name} не создана — выполните ячейку упражнения\")\n",
    "            return\n",
    "        if globals()[name] is Ellipsis:\n",
    "            print(f\"✗ {name} пока равна ... — замените многоточие своим кодом\")\n",
    "            return\n",
    "    globals()[\"_TESTS\"] = list(tests)\n",
    "    _run_tests()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "setup"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Таблицу с продажами по месяцам можно записать двумя способами. В **широком** формате каждый месяц — отдельный\n",
    "столбец. В **длинном** — месяц записан значением в столбце, и на каждую пару «город, месяц» приходится своя\n",
    "строка."
   ],
   "id": "text-10"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*Схема — в уроке на сайте: https://ds-edu-trainer.vercel.app/courses/pandas/melt-pivot*"
   ],
   "id": "component-15"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Данные одни и те же, форма разная. Широкая таблица компактна, её удобно читать. Длинная удобна для расчётов:\n",
    "по столбцу `month` можно группировать и фильтровать — а по названиям столбцов нельзя. Поэтому данные\n",
    "из отчётов и электронных таблиц, которые обычно приходят широкими, перед анализом часто «расплавляют».\n",
    "\n",
    "## melt: из широкой в длинную"
   ],
   "id": "text-16"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Широкая таблица: месяцы в столбцах\n",
    "import pandas as pd\n",
    "\n",
    "wide = pd.DataFrame({\n",
    "    \"city\": [\"Омск\", \"Тула\"],\n",
    "    \"jan\": [120, 95],\n",
    "    \"feb\": [130, 105],\n",
    "})\n",
    "wide"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "wide"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Метод `melt` («расплавить») превращает столбцы в строки:"
   ],
   "id": "text-24"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Длинная таблица: строка на каждую пару «город, месяц»\n",
    "long = wide.melt(id_vars=\"city\", var_name=\"month\", value_name=\"cups\")\n",
    "long"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "melt"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "- `id_vars` — столбцы, которые остаются как есть: они **определяют** наблюдение (здесь город);\n",
    "- все остальные столбцы «плавятся»: их названия уходят в один новый столбец, значения — в другой;\n",
    "- `var_name` и `value_name` — названия этих двух новых столбцов.\n",
    "\n",
    "Было 2 строки × 2 столбца с числами — стало 4 строки: по одной на каждую ячейку.\n",
    "\n",
    "Если названия не задать, столбцы получат имена `variable` и `value` — лучше сразу дать им осмысленные:"
   ],
   "id": "text-28"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Без var_name и value_name\n",
    "wide.melt(id_vars=\"city\")"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "melt-default"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "С длинной таблицей работают все знакомые приёмы:"
   ],
   "id": "text-38"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Группировка и фильтр по месяцу\n",
    "print(long.groupby(\"month\")[\"cups\"].sum())\n",
    "print(long[long[\"cups\"] > 100])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "long-use"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Теперь настоящие данные — баллы 30 студентов за первый семестр, по столбцу на предмет:"
   ],
   "id": "text-42"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Ведомость в широком формате\n",
    "scores = pd.read_csv(\"data/scores.csv\")\n",
    "scores.head(3)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "scores"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 1. Расплавить ведомость\n",
    "\n",
    "Переведите таблицу `scores` в длинный формат и сохраните в `marks`: столбцы `student_id`, `subject` (название\n",
    "предмета) и `score` (балл). В `n_marks` сохраните число строк.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`scores.melt(id_vars=\"student_id\", var_name=\"subject\", value_name=\"score\")`.\n",
    "\n",
    "</details>"
   ],
   "id": "to-long-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "marks = ...\n",
    "n_marks = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "to-long"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 1 — выполните после своего решения\n",
    "def test_marks():\n",
    "    \"marks — длинная таблица: студент, предмет, балл\"\n",
    "    assert isinstance(marks, pd.DataFrame), f\"marks — это {type(marks).__name__}, а нужна таблица\"\n",
    "    assert list(marks.columns) != [\"student_id\", \"variable\", \"value\"], \"столбцы называются variable и value: задайте свои названия — вспомните параметры var_name и value_name\"\n",
    "    assert list(marks.columns) == [\"student_id\", \"subject\", \"score\"], f\"столбцы сейчас {list(marks.columns)}, а нужны student_id, subject, score\"\n",
    "    assert len(marks) == 150, f\"в marks {len(marks)} строк, а должно быть 150: 30 студентов × 5 предметов\"\n",
    "    assert sorted(marks[\"subject\"].unique()) == [\"english\", \"history\", \"math\", \"physics\", \"programming\"], \"в столбце subject должны быть названия пяти предметов\"\n",
    "    assert marks[\"score\"].sum() == scores[[\"math\", \"physics\", \"programming\", \"history\", \"english\"]].sum().sum(), \"баллы не те: в столбце score — все оценки широкой таблицы\"\n",
    "\n",
    "\n",
    "def test_n():\n",
    "    \"n_marks — число оценок\"\n",
    "    assert n_marks == 150, f\"n_marks = {n_marks!r} — это не число строк marks\"\n",
    "\n",
    "_check(test_marks, test_n, names=[\"marks\",\"n_marks\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "to-long-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "marks = scores.melt(id_vars=\"student_id\", var_name=\"subject\", value_name=\"score\")\n",
    "n_marks = len(marks)\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "to-long-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 2. Вопросы к длинной таблице\n",
    "\n",
    "По таблице `marks` сохраните: в `subject_mean` — средний балл по каждому предмету, округлённый до одного\n",
    "знака; в `weak` — оценки ниже 50 баллов, отсортированные от самой низкой.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`marks.groupby(\"subject\")[\"score\"].mean().round(1)`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "`marks[marks[\"score\"] < 50].sort_values(\"score\")`.\n",
    "\n",
    "</details>"
   ],
   "id": "analysis-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "subject_mean = ...\n",
    "weak = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "analysis"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 2 — выполните после своего решения\n",
    "def test_mean():\n",
    "    \"subject_mean — средний балл по предметам, один знак\"\n",
    "    assert isinstance(subject_mean, pd.Series), f\"subject_mean — это {type(subject_mean).__name__}, а нужен Series\"\n",
    "    assert len(subject_mean) == 5 and \"math\" in subject_mean.index, \"в индексе должны быть пять предметов\"\n",
    "    assert abs(subject_mean[\"history\"] - 69.7) < 1e-9 and abs(subject_mean[\"english\"] - 66.0) < 1e-9, \"средние не те или не округлены до одного знака\"\n",
    "\n",
    "\n",
    "def test_weak():\n",
    "    \"weak — оценки ниже 50, от низких к высоким\"\n",
    "    assert isinstance(weak, pd.DataFrame), f\"weak — это {type(weak).__name__}, а нужна таблица\"\n",
    "    assert len(weak) == 10 and weak[\"score\"].max() < 50, f\"в weak {len(weak)} строк — проверьте условие\"\n",
    "    assert weak[\"score\"].tolist() == sorted(weak[\"score\"].tolist()), \"отсортируйте weak по баллу по возрастанию\"\n",
    "\n",
    "_check(test_mean, test_weak, names=[\"subject_mean\",\"weak\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "analysis-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "subject_mean = marks.groupby(\"subject\")[\"score\"].mean().round(1)\n",
    "weak = marks[marks[\"score\"] < 50].sort_values(\"score\")\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "analysis-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В широкой таблице на вопрос «у кого и по какому предмету меньше 50» пришлось бы отвечать отдельно для каждого\n",
    "столбца. В длинной это один фильтр.\n",
    "\n",
    "## pivot: из длинной в широкую\n",
    "\n",
    "Обратное превращение делает `pivot`. Ему нужно сказать, что будет в строках, что в столбцах и откуда\n",
    "брать значения:"
   ],
   "id": "text-61"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Обратно в широкую форму\n",
    "long.pivot(index=\"city\", columns=\"month\", values=\"cups\")"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "pivot"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Города оказались в индексе, а над названиями столбцов появилась подпись `month`. Сами столбцы встали\n",
    "по алфавиту: `feb` раньше `jan`. Чтобы получить совсем\n",
    "обычную таблицу, индекс превращают в столбец, а подпись убирают:"
   ],
   "id": "text-71"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Плоская широкая таблица\n",
    "back = long.pivot(index=\"city\", columns=\"month\", values=\"cups\").reset_index()\n",
    "back.columns.name = None\n",
    "back"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "pivot-flat"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## pivot и pivot_table\n",
    "\n",
    "Названия похожи, и параметры те же — `index`, `columns`, `values`. Разница одна, но важная: `pivot` только\n",
    "**переставляет** значения и ничего не считает. Поэтому каждой паре «строка, столбец» должно соответствовать\n",
    "ровно одно значение. В полной ведомости у каждого студента по каждому предмету две оценки — за два семестра:"
   ],
   "id": "text-77"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Ведомость в длинном формате\n",
    "grades = pd.read_csv(\"data/grades.csv\")\n",
    "grades.head(4)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "grades"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Два значения на одну ячейку\n",
    "# Эта ячейка показывает ошибку ValueError: она перехвачена, чтобы «Выполнить всё» не останавливалось.\n",
    "try:\n",
    "    grades.pivot(index=\"student\", columns=\"subject\", values=\"score\")\n",
    "except Exception as error:\n",
    "    print(f\"{type(error).__name__}: {error}\")"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "pivot-dup"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "> **Типичная ошибка: Index contains duplicate entries, cannot reshape.**\n",
    ">\n",
    "> `pivot` не знает, какое из двух значений положить в ячейку, и отказывается работать. Либо сначала оставьте\n",
    "> по одному значению на ячейку (например, отфильтруйте один семестр), либо возьмите `pivot_table` — она\n",
    "> свернёт несколько значений функцией `aggfunc`."
   ],
   "id": "component-88"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Правило простое: **переставить** — `pivot`, **посчитать** — `pivot_table`."
   ],
   "id": "text-93"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Вопрос.** ```python\n",
    "t = pd.DataFrame({\"who\": [\"a\", \"a\", \"b\", \"b\"], \"what\": [\"x\", \"y\", \"x\", \"y\"], \"n\": [1, 2, 3, 4]})\n",
    "print(t.pivot(index=\"who\", columns=\"what\", values=\"n\").shape)\n",
    "```\n",
    "\n",
    "Что выведет этот код?\n",
    "\n",
    "- `(4, 3)`\n",
    "- `(2, 2)`\n",
    "- `(2, 3)`\n",
    "\n",
    "<details><summary>Ответ</summary>\n",
    "\n",
    "`(2, 2)`\n",
    "\n",
    "В строках — разные значения `who` (a и b), в столбцах — разные значения `what` (x и y). Таблица 2 × 2; столбец `who` ушёл в индекс и в число столбцов не входит.\n",
    "\n",
    "</details>"
   ],
   "id": "quiz-96"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 3. Ведомость первого семестра\n",
    "\n",
    "Сохраните в `first_term` строки таблицы `grades` за первый семестр (`term` равен 1). Переведите их в широкий\n",
    "формат и сохраните в `sheet`: студенты в строках, предметы в столбцах, в ячейках — баллы.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`grades[grades[\"term\"] == 1]`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "`first_term.pivot(index=\"student\", columns=\"subject\", values=\"score\")`.\n",
    "\n",
    "</details>"
   ],
   "id": "to-wide-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "first_term = ...\n",
    "sheet = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "to-wide"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 3 — выполните после своего решения\n",
    "def test_term():\n",
    "    \"first_term — оценки первого семестра\"\n",
    "    assert isinstance(first_term, pd.DataFrame) and len(first_term) == 150 and (first_term[\"term\"] == 1).all(), \"first_term — 150 строк таблицы grades, где term равен 1\"\n",
    "\n",
    "\n",
    "def test_sheet():\n",
    "    \"sheet — ведомость: студенты в строках, предметы в столбцах\"\n",
    "    assert isinstance(sheet, pd.DataFrame), f\"sheet — это {type(sheet).__name__}, а нужна таблица\"\n",
    "    assert sheet.shape == (30, 5), f\"у sheet размер {sheet.shape}, а нужно 30 студентов × 5 предметов\"\n",
    "    assert \"Анна А.\" in sheet.index and \"Физика\" in sheet.columns, \"в строках — студенты, в столбцах — предметы\"\n",
    "    assert sheet.loc[\"Анна А.\", \"Физика\"] == 49 and sheet.loc[\"Анна А.\", \"Математика\"] == 68, \"в ячейках должны быть баллы первого семестра\"\n",
    "\n",
    "_check(test_term, test_sheet, names=[\"first_term\",\"sheet\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "to-wide-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "first_term = grades[grades[\"term\"] == 1]\n",
    "sheet = first_term.pivot(index=\"student\", columns=\"subject\", values=\"score\")\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "to-wide-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 4. Кто вырос за год\n",
    "\n",
    "Здесь значения нужно посчитать — значит, `pivot_table`. Сохраните в `terms` таблицу: студенты в строках,\n",
    "семестры в столбцах, в ячейках — средний балл студента за семестр. Добавьте столбец `growth` — прирост\n",
    "среднего балла от первого семестра ко второму. В `best_growth` сохраните имя студента с наибольшим приростом.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`grades.pivot_table(values=\"score\", index=\"student\", columns=\"term\", aggfunc=\"mean\")`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "Названия столбцов здесь — числа 1 и 2, без кавычек: `terms[2] - terms[1]`.\n",
    "\n",
    "</details>"
   ],
   "id": "progress-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "terms = ...\n",
    "# добавьте в terms столбец growth\n",
    "best_growth = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "progress"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 4 — выполните после своего решения\n",
    "def test_terms():\n",
    "    \"terms — средний балл студента по семестрам\"\n",
    "    assert isinstance(terms, pd.DataFrame), f\"terms — это {type(terms).__name__}, а нужна таблица\"\n",
    "    assert len(terms) == 30 and 1 in terms.columns and 2 in terms.columns, \"в строках — 30 студентов, в столбцах — семестры 1 и 2\"\n",
    "    assert abs(terms.loc[\"Анна А.\", 1] - 69.6) < 1e-9, \"в ячейках должен быть средний балл по пяти предметам\"\n",
    "\n",
    "\n",
    "def test_growth():\n",
    "    \"growth — прирост среднего балла, best_growth — кто вырос сильнее всех\"\n",
    "    assert \"growth\" in terms.columns, \"в terms нет столбца growth\"\n",
    "    assert abs(terms.loc[\"Павел Ч.\", \"growth\"] - (-11.8)) > 1e-9, \"знак перепутан: из второго семестра вычитают первый\"\n",
    "    assert abs(terms.loc[\"Павел Ч.\", \"growth\"] - 11.8) < 1e-9, \"growth — средний балл второго семестра минус средний балл первого\"\n",
    "    assert best_growth == \"Павел Ч.\", f\"best_growth = {best_growth!r}, а сильнее всех вырос другой студент\"\n",
    "\n",
    "_check(test_terms, test_growth, names=[\"terms\",\"best_growth\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "progress-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "terms = grades.pivot_table(values=\"score\", index=\"student\", columns=\"term\", aggfunc=\"mean\")\n",
    "terms[\"growth\"] = terms[2] - terms[1]\n",
    "best_growth = terms[\"growth\"].idxmax()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "progress-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Итоги\n",
    "\n",
    "- Широкий формат — значения признака в названиях столбцов; длинный — в отдельном столбце.\n",
    "- `melt(id_vars=..., var_name=..., value_name=...)` — из широкого в длинный.\n",
    "- `pivot(index=..., columns=..., values=...)` — из длинного в широкий; требует одно значение на ячейку.\n",
    "- `pivot_table` делает то же, но сворачивает несколько значений функцией `aggfunc`.\n",
    "- Для расчётов удобнее длинная таблица, для показа — широкая."
   ],
   "id": "text-127"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Подробнее в справочнике:** [pandas/melt-pivot](https://ds-edu-trainer.vercel.app/reference/pandas/melt-pivot) · [pandas/pivot-table](https://ds-edu-trainer.vercel.app/reference/pandas/pivot-table)"
   ],
   "id": "more"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "colab": {
   "provenance": []
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
