{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Пропуски\n",
    "\n",
    "Что такое NaN, как найти и посчитать пропуски, как они ведут себя в суммах, средних и сравнениях, когда строки с пропусками удалять, а когда заполнять — и чем опасно заполнение нулём.\n",
    "\n",
    "Курс pandas · урок `pd-missing` · [урок на сайте](https://ds-edu-trainer.vercel.app/courses/pandas/missing).\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",
    "    \"delivery_h1.csv\": \"\"\"\n",
    "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\n",
    "/J3UMnmxBTOmFnAnbFvg+D3/J/0DpZ50FA==\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",
    "(`days`) и оценка покупателя от 1 до 5 (`rating`)."
   ],
   "id": "text-10"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Доставка: первое полугодие\n",
    "import pandas as pd\n",
    "\n",
    "delivery = pd.read_csv(\"data/delivery_h1.csv\")\n",
    "delivery.head()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "load"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В первой же строке вместо оценки стоит `NaN` (not a number, «не число»). Так pandas обозначает **пропуск** —\n",
    "значение, которого нет: в файле на этом месте было пусто. Покупатель не поставил оценку.\n",
    "\n",
    "Заметьте: сроки и оценки показаны как `4.0`, хотя в файле целые числа. `NaN` — особое **дробное** значение,\n",
    "и столбец с ним становится дробным (`float64`). Как вернуть целые, разберём в следующем уроке.\n",
    "\n",
    "## Найти и посчитать\n",
    "\n",
    "Метод `isna` («is NA» — «отсутствует ли») возвращает маску: `True` там, где пропуск."
   ],
   "id": "text-16"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Маска пропусков\n",
    "delivery[\"rating\"].isna().head()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "isna"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "У таблицы `isna` возвращает таблицу масок, а `sum` по ней считает пропуски в каждом столбце — это первое,\n",
    "что стоит сделать с любой новой выгрузкой:"
   ],
   "id": "text-28"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Сколько пропусков в каждом столбце\n",
    "delivery.isna().sum()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "isna-sum"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Оценки нет у 287 заказов, срок не записан у 36. Обратный метод вы уже знаете: `notna` — «значение есть».\n",
    "А метод `count` считает именно непустые значения — в отличие от `len`, который считает строки:"
   ],
   "id": "text-33"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Строк 816, оценок 529\n",
    "print(len(delivery))\n",
    "print(delivery[\"rating\"].count())\n",
    "delivery.count()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "count"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 1. Доля пропусков\n",
    "\n",
    "Сохраните в `gap_share` долю пропусков в каждом столбце таблицы `delivery`, а в `no_rating` — строки, где\n",
    "оценка пропущена.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Доля — среднее маски: `delivery.isna().mean()`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "Строки по маске: `delivery[delivery[\"rating\"].isna()]`.\n",
    "\n",
    "</details>"
   ],
   "id": "gaps-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "gap_share = ...\n",
    "no_rating = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "gaps"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 1 — выполните после своего решения\n",
    "def test_share():\n",
    "    \"gap_share — доля пропусков в каждом столбце\"\n",
    "    assert isinstance(gap_share, pd.Series), f\"gap_share — это {type(gap_share).__name__}, а нужен Series\"\n",
    "    assert list(gap_share.index) == [\"order_id\", \"days\", \"rating\"], f\"метки сейчас {list(gap_share.index)}, а нужны названия трёх столбцов\"\n",
    "    assert gap_share[\"rating\"] != 287, \"это число пропусков (sum), а нужна доля — mean\"\n",
    "    assert abs(gap_share[\"rating\"] - 287 / 816) < 1e-9, f\"доля пропусков в rating — {gap_share['rating']} — это не доля пропусков\"\n",
    "    assert abs(gap_share[\"days\"] - 36 / 816) < 1e-9, \"доля пропусков в days не та: нужна доля по каждому столбцу\"\n",
    "\n",
    "\n",
    "def test_rows():\n",
    "    \"no_rating — строки без оценки\"\n",
    "    assert isinstance(no_rating, pd.DataFrame), f\"no_rating — это {type(no_rating).__name__}, а нужна таблица\"\n",
    "    assert len(no_rating) != 529, \"в no_rating строки С оценкой, а нужны без неё\"\n",
    "    assert len(no_rating) == 287 and no_rating[\"rating\"].isna().all(), f\"в no_rating {len(no_rating)} строк — проверьте маску\"\n",
    "\n",
    "_check(test_share, test_rows, names=[\"gap_share\",\"no_rating\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "gaps-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "gap_share = delivery.isna().mean()\n",
    "no_rating = delivery[delivery[\"rating\"].isna()]\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "gaps-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## NaN не равен ничему\n",
    "\n",
    "Сам пропуск в коде пишут как `np.nan` — значение из библиотеки NumPy. У него необычное свойство: он не равен\n",
    "ничему, даже самому себе. И любая арифметика с ним даёт снова `NaN`:"
   ],
   "id": "text-46"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Свойства NaN\n",
    "import numpy as np\n",
    "\n",
    "print(np.nan == np.nan)\n",
    "print((delivery[\"rating\"] == np.nan).sum())\n",
    "print(np.nan + 1)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "nan"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "> **Типичная ошибка: поиск пропусков через ==.**\n",
    ">\n",
    "> `delivery[delivery[\"rating\"] == np.nan]` всегда вернёт пустую таблицу: сравнение с `NaN` — `False` в каждой\n",
    "> строке. Пропуски ищут только методом `isna()`."
   ],
   "id": "component-54"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Пропуски в вычислениях\n",
    "\n",
    "`sum`, `mean`, `min`, `max` и другие итоги **пропускают** `NaN` и считают по тем значениям, что есть:"
   ],
   "id": "text-58"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Сумма, среднее и неверное среднее\n",
    "print(delivery[\"rating\"].sum())\n",
    "print(delivery[\"rating\"].mean())\n",
    "print(delivery[\"rating\"].sum() / len(delivery))"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "skip"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Средняя оценка 3.69 — это 1953 балла, делённые на 529 поставленных оценок. Третья строка делит на все 816\n",
    "заказов и получает 2.39: такой расчёт считает, будто не оценившие поставили ноль.\n",
    "\n",
    "Удобно, но есть оборотная сторона: вы можете не заметить, что среднее посчитано по половине таблицы. Поэтому\n",
    "сначала — `isna().sum()`.\n",
    "\n",
    "Со сравнениями хитрее. Сравнения `>`, `<`, `==` с пропуском дают `False` (и только `!=` — `True`: пропуск\n",
    "«не равен» ничему):"
   ],
   "id": "text-64"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Сколько заказов везли дольше 5 дней\n",
    "print((delivery[\"days\"] > 5).sum())\n",
    "print((delivery[\"days\"] > 5).mean())"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "compare"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "150 опозданий — верно. А доля 18.4 % занижена: `mean` маски делит на все 816 строк, и 36 заказов\n",
    "с неизвестным сроком молча записаны в «доставленные вовремя». Честная доля — среди заказов, срок которых\n",
    "известен."
   ],
   "id": "text-75"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Вопрос.** Что выведет `print(pd.Series([1, np.nan, 3]).mean())`?\n",
    "\n",
    "- `nan`\n",
    "- `1.3333333333333333`\n",
    "- `2.0`\n",
    "\n",
    "<details><summary>Ответ</summary>\n",
    "\n",
    "`2.0`\n",
    "\n",
    "`mean` пропускает `NaN`: остаются 1 и 3, их среднее — 2.0. Результат 1.33 получился бы, если бы пропуск считался нулём: (1 + 0 + 3) / 3.\n",
    "\n",
    "</details>"
   ],
   "id": "quiz-80"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 2. Честная доля опозданий\n",
    "\n",
    "Сохраните в `known_days` сроки доставки без пропусков, а в `late_share` — долю заказов, которые везли дольше\n",
    "5 дней, среди заказов с известным сроком.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Известные сроки — `delivery.loc[delivery[\"days\"].notna(), \"days\"]`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "Доля — среднее маски `known_days > 5`.\n",
    "\n",
    "</details>"
   ],
   "id": "late-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "known_days = ...\n",
    "late_share = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "late"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 2 — выполните после своего решения\n",
    "def test_known():\n",
    "    \"known_days — известные сроки\"\n",
    "    assert isinstance(known_days, pd.Series), f\"known_days — это {type(known_days).__name__}, а нужен Series: столбец days без пропусков\"\n",
    "    assert known_days.notna().all(), \"в known_days остались пропуски\"\n",
    "    assert len(known_days) == 780, f\"в known_days {len(known_days)} значений — должны остаться все известные сроки\"\n",
    "\n",
    "\n",
    "def test_share():\n",
    "    \"late_share — доля опозданий среди известных сроков\"\n",
    "    assert abs(late_share - 150 / 816) > 1e-9, \"доля посчитана по всем заказам: 36 заказов без срока записаны в «доставленные вовремя». Считайте по known_days\"\n",
    "    assert late_share != 150, \"150 — число опозданий, а нужна доля: среднее маски\"\n",
    "    assert abs(late_share - 150 / 780) < 1e-9, f\"late_share = {late_share!r} — это не доля опозданий среди известных сроков\"\n",
    "\n",
    "_check(test_known, test_share, names=[\"known_days\",\"late_share\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "late-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "known_days = delivery.loc[delivery[\"days\"].notna(), \"days\"]\n",
    "late_share = (known_days > 5).mean()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "late-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Удалить: dropna\n",
    "\n",
    "`dropna` убирает строки с пропусками. Без параметров — все строки, где пропущено **хоть что-нибудь**. Параметр\n",
    "`subset` ограничивает проверку указанными столбцами:"
   ],
   "id": "text-97"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Полные строки и строки с известным сроком\n",
    "print(len(delivery.dropna()))\n",
    "print(len(delivery.dropna(subset=[\"days\"])))"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "dropna"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Из 816 строк полностью заполнены 505. Если нас интересует только срок, выбрасывать 311 строк незачем —\n",
    "достаточно убрать 36.\n",
    "\n",
    "## Заполнить: fillna\n",
    "\n",
    "`fillna` ставит на место пропусков указанное значение:"
   ],
   "id": "text-104"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Ноль вместо пропуска\n",
    "delivery[\"rating\"].fillna(0).head()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "fillna"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Осторожно: выбранное значение становится «данными», и итоги начинают его учитывать."
   ],
   "id": "text-113"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Средняя оценка до и после заполнения нулём\n",
    "print(delivery[\"rating\"].mean())\n",
    "print(delivery[\"rating\"].fillna(0).mean())"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "fillna-trap"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "> **Типичная ошибка: ноль — это не «нет данных».**\n",
    ">\n",
    "> Ноль — тоже значение: после `fillna(0)` средняя оценка упала с 3.69 до 2.39, хотя ни один покупатель не стал\n",
    "> недовольнее. Заполнять нулём можно только там, где пропуск действительно значит «ноль» — например, число\n",
    "> возвратов у заказа, по которому возвратов не было."
   ],
   "id": "component-118"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Чаще пропуски в числах заполняют типичным значением — средним или **медианой**. Медиана — значение\n",
    "посередине: половина данных меньше неё, половина больше. В `describe` она подписана `50%`, отдельно её\n",
    "считает метод `median`:"
   ],
   "id": "text-123"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Заполнить срок медианой\n",
    "median_days = delivery[\"days\"].median()\n",
    "print(median_days)\n",
    "print(delivery[\"days\"].fillna(median_days).isna().sum())"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "fillna-median"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Как и остальные методы, `dropna` и `fillna` возвращают новый объект, а исходный не меняют.\n",
    "\n",
    "Что выбрать — удалить или заполнить? Общего ответа нет, зависит от вопроса:\n",
    "\n",
    "- считаете итог по одному столбцу — ничего не делайте, `mean` и `sum` сами пропустят `NaN`;\n",
    "- для расчёта нужны полные строки — `dropna(subset=[...])` по нужным столбцам;\n",
    "- значение нужно в каждой строке (для следующего шага, для графика) — `fillna`, и запомните, чем заполнили."
   ],
   "id": "text-129"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 3. Только оценённые\n",
    "\n",
    "Сохраните в `rated` заказы, у которых есть оценка (срок при этом может быть пропущен), а в `avg_rating` —\n",
    "среднюю оценку.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`delivery.dropna(subset=[\"rating\"])`.\n",
    "\n",
    "</details>"
   ],
   "id": "rated-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "rated = ...\n",
    "avg_rating = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "rated"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 3 — выполните после своего решения\n",
    "def test_rated():\n",
    "    \"rated — заказы с оценкой\"\n",
    "    assert isinstance(rated, pd.DataFrame), f\"rated — это {type(rated).__name__}, а нужна таблица\"\n",
    "    assert len(rated) != 505, \"в rated 505 строк: dropna() без subset убрал и заказы, у которых пропущен только срок. Вспомните параметр subset\"\n",
    "    assert len(rated) == 529, f\"в rated {len(rated)} строк — должны остаться все заказы с оценкой\"\n",
    "    assert rated[\"rating\"].notna().all(), \"в rated остались строки без оценки\"\n",
    "\n",
    "\n",
    "def test_avg():\n",
    "    \"avg_rating — средняя оценка\"\n",
    "    assert abs(avg_rating - 2.39338) > 1e-4, \"это среднее с нулями вместо пропусков: нули — не оценки. Считайте среднее по rated\"\n",
    "    assert abs(avg_rating - 3.691871) < 1e-5, f\"avg_rating = {avg_rating!r} — это не средняя оценка\"\n",
    "\n",
    "_check(test_rated, test_avg, names=[\"rated\",\"avg_rating\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "rated-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "rated = delivery.dropna(subset=[\"rating\"])\n",
    "avg_rating = rated[\"rating\"].mean()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "rated-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 4. Заполнить сроки\n",
    "\n",
    "В таблице `filled` — копия `delivery`. Заполните в ней пропуски столбца `days` медианой этого столбца.\n",
    "В `n_gaps_left` сохраните, сколько пропусков осталось в `filled[\"days\"]`.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`fillna` возвращает новый Series — запишите его обратно: `filled[\"days\"] = filled[\"days\"].fillna(...)`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "Медиана — `filled[\"days\"].median()`; оставшиеся пропуски — `filled[\"days\"].isna().sum()`.\n",
    "\n",
    "</details>"
   ],
   "id": "fill-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "filled = delivery.copy()\n",
    "# заполните пропуски в столбце days таблицы filled медианой\n",
    "n_gaps_left = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "fill"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 4 — выполните после своего решения\n",
    "def test_filled():\n",
    "    \"в filled пропуски days заполнены медианой\"\n",
    "    assert isinstance(filled, pd.DataFrame) and filled.shape == (816, 3), \"filled должна остаться таблицей 816 × 3\"\n",
    "    assert filled[\"days\"].isna().sum() != 36, \"пропуски в filled[\\\"days\\\"] остались. fillna возвращает новый Series — его нужно записать обратно в столбец\"\n",
    "    assert filled[\"days\"].isna().sum() == 0, \"в filled[\\\"days\\\"] ещё есть пропуски\"\n",
    "    assert filled[\"days\"].sum() != 3257, \"пропуски заполнены нулями, а нужна медиана столбца\"\n",
    "    assert filled[\"days\"].sum() == 3401, \"пропуски нужно заполнить медианой срока\"\n",
    "    assert filled[\"rating\"].isna().sum() == 287, \"столбец rating заполнять не нужно\"\n",
    "\n",
    "\n",
    "def test_left():\n",
    "    \"n_gaps_left — сколько пропусков осталось в days\"\n",
    "    assert n_gaps_left == 0, f\"n_gaps_left = {n_gaps_left!r}, а после заполнения пропусков быть не должно\"\n",
    "\n",
    "\n",
    "def test_delivery_kept():\n",
    "    \"таблица delivery не изменилась\"\n",
    "    assert delivery[\"days\"].isna().sum() == 36, \"пропуски в delivery тоже заполнены, а менять нужно только filled. Выполните ячейку «Доставка: первое полугодие» ещё раз\"\n",
    "\n",
    "_check(test_filled, test_left, test_delivery_kept, names=[\"n_gaps_left\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "fill-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "filled = delivery.copy()\n",
    "filled[\"days\"] = filled[\"days\"].fillna(filled[\"days\"].median())\n",
    "n_gaps_left = filled[\"days\"].isna().sum()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "fill-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Итоги\n",
    "\n",
    "- `NaN` — пропуск; `isna()` — маска пропусков, `isna().sum()` — сколько их в каждом столбце.\n",
    "- `count()` считает непустые значения, `len` — строки.\n",
    "- `sum`, `mean` и другие итоги пропускают `NaN`; сравнения `>`, `<`, `==` с `NaN` дают `False`.\n",
    "- `dropna(subset=[...])` удаляет строки с пропусками, `fillna(значение)` заполняет.\n",
    "- Заполнение меняет итоги: ноль вместо пропуска — частая и тихая ошибка."
   ],
   "id": "text-152"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Подробнее в справочнике:** [pandas/missing](https://ds-edu-trainer.vercel.app/reference/pandas/missing) · [pandas/stats](https://ds-edu-trainer.vercel.app/reference/pandas/stats)"
   ],
   "id": "more"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "colab": {
   "provenance": []
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
