{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Мини-проект: ведомость\n",
    "\n",
    "Оценки 30 студентов за год в длинном формате: собрать годовую ведомость, найти лучших студентов и сильнейшие группы по предметам, сравнить семестры, посчитать долю незачётов и вернуть ведомость в длинную форму.\n",
    "\n",
    "Курс pandas · урок `pd-project-grades` · [урок на сайте](https://ds-edu-trainer.vercel.app/courses/pandas/project-grades).\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",
    "    \"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": [
    "Учебная часть прислала оценки за год и просит отчёт для кафедры. В файле `data/grades.csv` — 30 студентов\n",
    "трёх групп, пять предметов, два семестра; баллы от 0 до 100, зачёт — от 50. Одна строка — одна оценка.\n",
    "Новых методов в проекте нет."
   ],
   "id": "text-10"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Оценки: одна строка — одна оценка\n",
    "import pandas as pd\n",
    "\n",
    "grades = pd.read_csv(\"data/grades.csv\")\n",
    "print(grades.shape)\n",
    "grades.head(4)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "load"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 1. Годовая ведомость\n",
    "\n",
    "Данные в длинном формате удобны для расчётов, но преподаватели привыкли к ведомости: студенты в строках,\n",
    "предметы в столбцах. У каждого студента по каждому предмету две оценки — значит, их нужно свести в одну."
   ],
   "id": "text-16"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 1. Студенты × предметы\n",
    "\n",
    "Сохраните в `sheet` сводную таблицу: студенты в строках, предметы в столбцах, в ячейках — средний балл\n",
    "за два семестра, округлённый до одного знака.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`grades.pivot_table(values=\"score\", index=\"student\", columns=\"subject\", aggfunc=\"mean\").round(1)`.\n",
    "\n",
    "</details>"
   ],
   "id": "sheet-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "sheet = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "sheet"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 1 — выполните после своего решения\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[\"Анна А.\", \"Физика\"] != 109, \"в ячейках суммы двух семестров, а нужно среднее\"\n",
    "    assert sheet.loc[\"Анна А.\", \"Физика\"] == 54.5 and sheet.loc[\"Алексей Я.\", \"Программирование\"] == 98.5, \"в ячейках должен быть средний балл за два семестра, округлённый до одного знака\"\n",
    "\n",
    "_check(test_sheet, names=[\"sheet\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "sheet-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "sheet = grades.pivot_table(values=\"score\", index=\"student\", columns=\"subject\", aggfunc=\"mean\").round(1)\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "sheet-solution"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Начало ведомости\n",
    "sheet.head(5)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "sheet-view"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 2. Лучшие студенты"
   ],
   "id": "text-30"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 2. Тройка лидеров\n",
    "\n",
    "Сохраните в `student_avg` средний балл каждого студента по всем оценкам за год. В `top3` — трёх студентов\n",
    "с наибольшим средним баллом; баллы округлите до одного знака.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`grades.groupby(\"student\")[\"score\"].mean()`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "`student_avg.nlargest(3).round(1)`.\n",
    "\n",
    "</details>"
   ],
   "id": "leaders-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "student_avg = ...\n",
    "top3 = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "leaders"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 2 — выполните после своего решения\n",
    "def test_avg():\n",
    "    \"student_avg — средний балл каждого студента за год\"\n",
    "    assert isinstance(student_avg, pd.Series) and len(student_avg) == 30, \"student_avg — Series по 30 студентам\"\n",
    "    assert abs(student_avg[\"Алексей Я.\"] - 89.9) < 1e-9, \"средние не те: нужен средний балл студента по всем оценкам\"\n",
    "\n",
    "\n",
    "def test_top():\n",
    "    \"top3 — три лучших студента, балл до одного знака\"\n",
    "    assert isinstance(top3, pd.Series) and len(top3) == 3, \"top3 — три наибольших значения student_avg\"\n",
    "    assert list(top3.index) == [\"Алексей Я.\", \"Наталья П.\", \"Ольга А.\"], f\"в top3 сейчас {list(top3.index)} — это не три студента с наибольшим средним\"\n",
    "    assert top3.tolist() == [89.9, 81.0, 79.3], f\"баллы в top3 сейчас {top3.tolist()}: округлите до одного знака\"\n",
    "\n",
    "_check(test_avg, test_top, names=[\"student_avg\",\"top3\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "leaders-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "student_avg = grades.groupby(\"student\")[\"score\"].mean()\n",
    "top3 = student_avg.nlargest(3).round(1)\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "leaders-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 3. Группы и предметы"
   ],
   "id": "text-40"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 3. Сильные стороны групп\n",
    "\n",
    "Сохраните в `group_subject` сводную таблицу средних баллов: группы в строках, предметы в столбцах, с итоговой\n",
    "строкой и столбцом «Все», округление до одного знака. В `math_leader` сохраните группу с лучшим средним\n",
    "баллом по математике.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "К `pivot_table` добавьте `margins=True, margins_name=\"Все\"`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "`group_subject[\"Математика\"].idxmax()`.\n",
    "\n",
    "</details>"
   ],
   "id": "groups-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "group_subject = ...\n",
    "math_leader = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "groups"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 3 — выполните после своего решения\n",
    "def test_table():\n",
    "    \"group_subject — средний балл: группы × предметы, с итогами «Все»\"\n",
    "    assert isinstance(group_subject, pd.DataFrame), f\"group_subject — это {type(group_subject).__name__}, а нужна таблица\"\n",
    "    assert \"Все\" in group_subject.index and \"Все\" in group_subject.columns, \"в таблице нет итогов: вспомните параметры margins и margins_name\"\n",
    "    assert group_subject.shape == (4, 6), f\"у group_subject размер {group_subject.shape}, а нужно 4 строки (3 группы и итог) × 6 столбцов (5 предметов и итог)\"\n",
    "    assert group_subject.loc[\"ИТ-22\", \"Программирование\"] == 77.4 and group_subject.loc[\"Все\", \"Все\"] == 68.2, \"в ячейках должен быть средний балл, округлённый до одного знака\"\n",
    "\n",
    "\n",
    "def test_leader():\n",
    "    \"math_leader — лучшая группа по математике\"\n",
    "    assert math_leader == \"ИТ-23\", f\"math_leader = {math_leader!r}, а лучший средний балл по математике у другой группы\"\n",
    "\n",
    "_check(test_table, test_leader, names=[\"group_subject\",\"math_leader\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "groups-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "group_subject = grades.pivot_table(values=\"score\", index=\"group\", columns=\"subject\", aggfunc=\"mean\", margins=True, margins_name=\"Все\").round(1)\n",
    "math_leader = group_subject[\"Математика\"].idxmax()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "groups-solution"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Средние баллы групп\n",
    "group_subject"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "groups-view"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*После решения упражнения выше:*\n",
    "\n",
    "У группы ИТ-22 заметный отрыв по программированию — 77.4 против 65–67 у остальных. Заметна и разница\n",
    "по математике: ИТ-21 отстаёт от двух других групп на 6–9 баллов. А по английскому группы почти равны."
   ],
   "id": "component-54"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 4. Первый семестр против второго"
   ],
   "id": "text-58"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 4. Прогресс по предметам\n",
    "\n",
    "Сохраните в `by_term` сводную таблицу: предметы в строках, семестры в столбцах, в ячейках — средний балл.\n",
    "Добавьте столбец `growth` — разность второго и первого семестра. В `grew_most` сохраните предмет, по которому\n",
    "баллы выросли сильнее всего, в `fell` — строки предметов, по которым баллы упали.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Названия столбцов — числа: `by_term[2] - by_term[1]`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "`by_term[\"growth\"].idxmax()`; упавшие — фильтр `by_term[\"growth\"] < 0`.\n",
    "\n",
    "</details>"
   ],
   "id": "progress-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "by_term = ...\n",
    "# добавьте в by_term столбец growth\n",
    "grew_most = ...\n",
    "fell = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "progress"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 4 — выполните после своего решения\n",
    "def test_by_term():\n",
    "    \"by_term — средний балл: предметы × семестры, и прирост\"\n",
    "    assert isinstance(by_term, pd.DataFrame), f\"by_term — это {type(by_term).__name__}, а нужна таблица\"\n",
    "    assert len(by_term) == 5 and 1 in by_term.columns and 2 in by_term.columns, \"в строках — пять предметов, в столбцах — семестры 1 и 2\"\n",
    "    assert \"growth\" in by_term.columns, \"в by_term нет столбца growth\"\n",
    "    assert abs(by_term.loc[\"Физика\", \"growth\"] - 2.1666667) < 1e-5, \"growth не тот: нужен средний балл второго семестра минус первого\"\n",
    "\n",
    "\n",
    "def test_answers():\n",
    "    \"grew_most — где баллы выросли сильнее всего, fell — где упали\"\n",
    "    assert grew_most == \"Физика\", f\"grew_most = {grew_most!r}, а сильнее всего выросли баллы по другому предмету\"\n",
    "    assert isinstance(fell, pd.DataFrame), f\"fell — это {type(fell).__name__}, а нужна таблица\"\n",
    "    assert list(fell.index) == [\"История\"], f\"в fell сейчас {list(fell.index)}: нужны предметы с отрицательным приростом\"\n",
    "\n",
    "_check(test_by_term, test_answers, names=[\"by_term\",\"grew_most\",\"fell\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "progress-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "by_term = grades.pivot_table(values=\"score\", index=\"subject\", columns=\"term\", aggfunc=\"mean\")\n",
    "by_term[\"growth\"] = by_term[2] - by_term[1]\n",
    "grew_most = by_term[\"growth\"].idxmax()\n",
    "fell = by_term[by_term[\"growth\"] < 0]\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "progress-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 5. Незачёты"
   ],
   "id": "text-69"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 5. Доля незачётов по группам\n",
    "\n",
    "Добавьте в `grades` столбец `passed`: `True`, если балл не меньше 50. Сохраните в `pass_table` таблицу долей:\n",
    "группы в строках, `passed` в столбцах, доли внутри строки. В `fail_21` сохраните долю незачётов в группе ИТ-21.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`pd.crosstab(grades[\"group\"], grades[\"passed\"], normalize=\"index\")`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "Незачёт — столбец `False`: `pass_table.loc[\"ИТ-21\", False]`.\n",
    "\n",
    "</details>"
   ],
   "id": "passed-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# добавьте в grades столбец passed\n",
    "pass_table = ...\n",
    "fail_21 = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "passed"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 5 — выполните после своего решения\n",
    "def test_passed():\n",
    "    \"passed — оценка от 50 баллов\"\n",
    "    assert \"passed\" in grades.columns and grades[\"passed\"].dtype == bool, \"в grades нужен столбец-маска passed\"\n",
    "    assert (~grades[\"passed\"]).sum() != 17, \"50 баллов — это зачёт\"\n",
    "    assert (~grades[\"passed\"]).sum() == 15, \"маска не та: незачёт — меньше 50 баллов\"\n",
    "\n",
    "\n",
    "def test_table():\n",
    "    \"pass_table — доля зачётов и незачётов по группам\"\n",
    "    assert isinstance(pass_table, pd.DataFrame), f\"pass_table — это {type(pass_table).__name__}, а нужна таблица\"\n",
    "    assert pass_table.shape == (3, 2), f\"у pass_table размер {pass_table.shape}, а нужно 3 группы × 2 значения\"\n",
    "    assert abs(pass_table.loc[\"ИТ-21\"].sum() - 1) < 1e-9, \"доли в каждой строке должны давать в сумме 1\"\n",
    "    assert abs(fail_21 - 0.08) < 1e-9, f\"fail_21 = {fail_21!r} — это не доля незачётов в ИТ-21\"\n",
    "\n",
    "_check(test_passed, test_table, names=[\"pass_table\",\"fail_21\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "passed-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "grades[\"passed\"] = grades[\"score\"] >= 50\n",
    "pass_table = pd.crosstab(grades[\"group\"], grades[\"passed\"], normalize=\"index\")\n",
    "fail_21 = pass_table.loc[\"ИТ-21\", False]\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "passed-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 6. Обратно в длинную форму\n",
    "\n",
    "Ведомость `sheet` широкая. Чтобы найти в ней все высокие баллы разом, её удобнее снова «расплавить».\n",
    "Студенты сейчас в индексе — `melt` работает со столбцами, поэтому сначала `reset_index`."
   ],
   "id": "text-79"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 6. Отличные результаты\n",
    "\n",
    "Переведите `sheet` в длинный формат и сохраните в `year_marks`: столбцы `student`, `subject`, `mean_score`.\n",
    "В `stars` сохраните строки с годовым баллом от 90, отсортированные по убыванию балла.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`sheet.reset_index().melt(id_vars=\"student\", var_name=\"subject\", value_name=\"mean_score\")`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "Фильтр `year_marks[\"mean_score\"] >= 90` и `sort_values(\"mean_score\", ascending=False)`.\n",
    "\n",
    "</details>"
   ],
   "id": "stars-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "year_marks = ...\n",
    "stars = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "stars"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 6 — выполните после своего решения\n",
    "def test_long():\n",
    "    \"year_marks — длинная таблица: студент, предмет, средний балл за год\"\n",
    "    assert isinstance(year_marks, pd.DataFrame), f\"year_marks — это {type(year_marks).__name__}, а нужна таблица\"\n",
    "    assert list(year_marks.columns) == [\"student\", \"subject\", \"mean_score\"], f\"столбцы сейчас {list(year_marks.columns)}, а нужны student, subject, mean_score\"\n",
    "    assert len(year_marks) == 150, f\"в year_marks {len(year_marks)} строк, а должно быть 150: 30 студентов × 5 предметов\"\n",
    "\n",
    "\n",
    "def test_stars():\n",
    "    \"stars — годовые баллы от 90, по убыванию\"\n",
    "    assert isinstance(stars, pd.DataFrame), f\"stars — это {type(stars).__name__}, а нужна таблица\"\n",
    "    assert list(stars.columns) == [\"student\", \"subject\", \"mean_score\"], \"stars — строки таблицы year_marks со столбцами student, subject, mean_score\"\n",
    "    assert len(stars) == 5 and stars[\"mean_score\"].min() >= 90, f\"в stars {len(stars)} строк — проверьте условие\"\n",
    "    assert stars[\"mean_score\"].tolist() == sorted(stars[\"mean_score\"].tolist(), reverse=True), \"отсортируйте stars по убыванию mean_score\"\n",
    "    assert stars.iloc[0][\"student\"] == \"Алексей Я.\" and stars.iloc[0][\"subject\"] == \"Программирование\", \"первой должна быть строка с наибольшим баллом\"\n",
    "\n",
    "_check(test_long, test_stars, names=[\"year_marks\",\"stars\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "stars-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "year_marks = sheet.reset_index().melt(id_vars=\"student\", var_name=\"subject\", value_name=\"mean_score\")\n",
    "stars = year_marks[year_marks[\"mean_score\"] >= 90].sort_values(\"mean_score\", ascending=False)\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "stars-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Отчёт"
   ],
   "id": "text-92"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Итог\n",
    "print(\"Лучшие студенты:\")\n",
    "print(top3)\n",
    "print(\"Лучшая группа по математике:\", math_leader)\n",
    "print(\"Сильнее всего выросли баллы:\", grew_most, \"· упали:\", list(fell.index))\n",
    "print(\"Доля незачётов в ИТ-21:\", round(fail_21 * 100), \"%\")\n",
    "print(\"Годовых баллов от 90:\", len(stars))"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "summary"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Выводы"
   ],
   "id": "text-96"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*После решения всех упражнений урока:*\n",
    "\n",
    "- Лучший студент года — Алексей Я. со средним баллом 89.9; на его счету три из пяти годовых результатов\n",
    "  от 90 баллов.\n",
    "- Группа ИТ-22 сильнее остальных в программировании, ИТ-23 — в математике.\n",
    "- От первого семестра ко второму выросли баллы по физике (+2.2) и программированию; по истории немного упали.\n",
    "- Незачётов мало: 15 из 300 оценок. Больше всего — в ИТ-21, 8 %."
   ],
   "id": "component-99"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В этом проекте данные дважды сменили форму: длинная → широкая ведомость → снова длинная. Это обычная работа\n",
    "с таблицами: считать удобнее в длинной форме, смотреть — в широкой."
   ],
   "id": "text-106"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Подробнее в справочнике:** [pandas/pivot-table](https://ds-edu-trainer.vercel.app/reference/pandas/pivot-table) · [pandas/melt-pivot](https://ds-edu-trainer.vercel.app/reference/pandas/melt-pivot) · [pandas/groupby](https://ds-edu-trainer.vercel.app/reference/pandas/groupby)"
   ],
   "id": "more"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "colab": {
   "provenance": []
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
