{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Мини-проект: грязная выгрузка клиентов, часть 2\n",
    "\n",
    "Продолжаем чистить выгрузку клиентов: даты в двух форматах, баллы текстом, дубли. Сверяем результат с эталоном и отвечаем маркетингу: 257 строк превращаются в 240 клиентов.\n",
    "\n",
    "Курс pandas · урок `pd-project-clean-2` · [урок на сайте](https://ds-edu-trainer.vercel.app/courses/pandas/project-clean-2).\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",
    "    \"customers_raw.csv\": \"\"\"\n",
    "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\n",
    "\"\"\",\n",
    "    \"customers.csv\": \"\"\"\n",
    "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\n",
    "MbA4youwiqExZ9I32UIn978sij3j\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": [
    "Продолжение [первой части](https://ds-edu-trainer.vercel.app/courses/pandas/project-clean): маркетинг ждёт, сколько клиентов в каждом городе\n",
    "и сколько бонусных баллов у них в среднем. Текстовые столбцы мы уже почистили, остались даты, числа\n",
    "и дубликаты.\n",
    "\n",
    "## Подготовка\n",
    "\n",
    "Ячейка ниже повторяет первую часть: загружает выгрузку и чистит коды, имена, города, сегменты и почту — это\n",
    "решения упражнений первой части. Выполните её (или сразу первое упражнение — она выполнится сама). Последняя\n",
    "строка напоминает, сколько полных повторов было в сырой выгрузке: их понадобится сравнить."
   ],
   "id": "text-10"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Шаги первой части\n",
    "import pandas as pd\n",
    "\n",
    "customers = pd.read_csv(\"data/customers_raw.csv\")\n",
    "raw_duplicates = customers.duplicated().sum()\n",
    "short = {\"Спб\": \"Санкт-Петербург\", \"С.-Петербург\": \"Санкт-Петербург\", \"Екб\": \"Екатеринбург\"}\n",
    "customers[\"customer_id\"] = customers[\"customer_id\"].str.strip().str.upper()\n",
    "customers[\"name\"] = customers[\"name\"].str.strip().str.replace(\"  \", \" \").str.title()\n",
    "customers[\"city\"] = customers[\"city\"].str.strip().str.title().replace(short)\n",
    "customers[\"segment\"] = customers[\"segment\"].str.strip().str.lower()\n",
    "customers[\"email\"] = customers[\"email\"].str.lower()\n",
    "print(\"полных повторов в сырой выгрузке:\", raw_duplicates)\n",
    "customers.head(5)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "prepare"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 1. Дата регистрации\n",
    "\n",
    "В столбце `signup_date` два формата: `2025-06-12` и `24.08.2024`."
   ],
   "id": "text-22"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 1. Две записи даты\n",
    "\n",
    "Добавьте в `customers` столбец `signup` — дату регистрации настоящей датой. В `no_date` сохраните число строк,\n",
    "где даты нет.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Разберите каждый формат отдельно с `errors=\"coerce\"`: `\"%Y-%m-%d\"` и `\"%d.%m.%Y\"`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "Соберите: `iso.fillna(dotted)`; пропуски — `customers[\"signup\"].isna().sum()`.\n",
    "\n",
    "</details>"
   ],
   "id": "signup-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# добавьте в customers столбец signup — дату регистрации\n",
    "no_date = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "signup"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 1 — выполните после своего решения\n",
    "def test_signup():\n",
    "    \"signup — даты регистрации\"\n",
    "    assert \"signup\" in customers.columns, \"в customers нет столбца signup\"\n",
    "    assert str(customers[\"signup\"].dtype).startswith(\"datetime64\"), f\"тип signup — {customers['signup'].dtype}, а нужны даты\"\n",
    "    assert customers[\"signup\"].isna().sum() < 50, \"распознан только один формат: соберите оба через fillna — дат с точками в файле тоже много\"\n",
    "    assert customers[\"signup\"].isna().sum() == 6, f\"в signup {customers['signup'].isna().sum()} пропусков: пропуски должны остаться только там, где даты не было\"\n",
    "    assert customers[\"signup\"].max() <= pd.to_datetime(\"2025-06-30\"), \"есть даты позже июня 2025 года — день и месяц перепутаны: разбирайте каждый формат явно\"\n",
    "    assert customers[\"signup\"].dt.year.value_counts()[2023] == 109, \"даты прочитаны неверно: проверьте оба формата\"\n",
    "\n",
    "\n",
    "def test_no_date():\n",
    "    \"no_date — у скольких строк даты нет\"\n",
    "    assert no_date == 6, f\"no_date = {no_date!r} — это не число строк без даты регистрации\"\n",
    "\n",
    "_check(test_signup, test_no_date, names=[\"no_date\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "signup-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "iso = pd.to_datetime(customers[\"signup_date\"], format=\"%Y-%m-%d\", errors=\"coerce\")\n",
    "dotted = pd.to_datetime(customers[\"signup_date\"], format=\"%d.%m.%Y\", errors=\"coerce\")\n",
    "customers[\"signup\"] = iso.fillna(dotted)\n",
    "no_date = customers[\"signup\"].isna().sum()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "signup-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Пропуски остались только там, где даты не было в самой выгрузке. Восстановить её неоткуда, и заполнять\n",
    "её чем-то «средним» нельзя: оставляем пропуск.\n",
    "\n",
    "## Шаг 2. Бонусные баллы"
   ],
   "id": "text-34"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Что записано в bonus\n",
    "print(\"пустых полей:\", customers[\"bonus\"].isna().sum())\n",
    "customers[\"bonus\"].value_counts().head(8)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "bonus-before"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Числа, числа с пробелом и слово «нет». Есть и пустые поля — `value_counts` их не показывает, поэтому\n",
    "последняя строка считает их отдельно: 11 пропусков. Маркетологи подтвердили: «нет» и пустое поле значат, что\n",
    "баллов нет, — то есть ноль."
   ],
   "id": "text-41"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 2. Баллы числом\n",
    "\n",
    "Добавьте в `customers` столбец `points` — баллы целым числом: пробелы между тысячами убраны, «нет» и пропуск\n",
    "превращены в 0. В `total_points` сохраните сумму баллов по всем строкам.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Порядок важен: сначала `.str.replace(\" \", \"\")`, потом `pd.to_numeric(..., errors=\"coerce\")`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "Затем `.fillna(0).astype(\"int64\")`.\n",
    "\n",
    "</details>"
   ],
   "id": "points-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# добавьте в customers столбец points — бонусные баллы целым числом\n",
    "total_points = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "points"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 2 — выполните после своего решения\n",
    "def test_points():\n",
    "    \"points — баллы целыми числами, «нет» и пропуск — ноль\"\n",
    "    assert \"points\" in customers.columns, \"в customers нет столбца points\"\n",
    "    assert customers[\"points\"].isna().sum() == 0, \"в points остались пропуски: «нет» и пустое поле значат ноль баллов\"\n",
    "    assert str(customers[\"points\"].dtype) in (\"int64\", \"int32\"), f\"тип points — {customers['points'].dtype}, а нужен целый\"\n",
    "    assert customers[\"points\"].max() == 3100, \"баллы с пробелом между тысячами потерялись: уберите пробел до перевода в числа\"\n",
    "    assert customers.loc[1, \"points\"] == 2400, f\"у клиента в строке 1 баллов {customers.loc[1, 'points']}, а в файле записано «2 400»: уберите пробел до перевода в числа\"\n",
    "\n",
    "\n",
    "def test_total():\n",
    "    \"total_points — сумма баллов\"\n",
    "    assert total_points != 138910, \"значения с пробелом («2 400») превратились в пропуски и потом в нули: сначала уберите пробел, потом переводите в числа\"\n",
    "    assert total_points == 219360, f\"total_points = {total_points!r} не совпадает с суммой баллов всех строк: проверьте, что points посчитан для каждой строки\"\n",
    "\n",
    "_check(test_points, test_total, names=[\"total_points\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "points-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "points = pd.to_numeric(customers[\"bonus\"].str.replace(\" \", \"\"), errors=\"coerce\")\n",
    "customers[\"points\"] = points.fillna(0).astype(\"int64\")\n",
    "total_points = customers[\"points\"].sum()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "points-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "> **Типичная ошибка: coerce съел числа с пробелом.**\n",
    ">\n",
    "> Если перевести в числа, не убрав пробелы, `2 400` станет пропуском, а после `fillna(0)` — нулём. Ошибки нет,\n",
    "> а треть баллов пропала. После `errors=\"coerce\"` всегда сверяйте число пропусков с тем, что ожидали: здесь\n",
    "> их должно быть 31 — 20 раз «нет» и 11 пустых полей."
   ],
   "id": "component-54"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 3. Дубликаты\n",
    "\n",
    "Теперь, когда текст чистый, посмотрим на повторы ещё раз:"
   ],
   "id": "text-59"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Полных повторов и повторов по customer_id\n",
    "print(customers.duplicated().sum())\n",
    "print(customers.duplicated(subset=[\"customer_id\"]).sum())"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "dups"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В сырой выгрузке полных повторов было 10 (последняя строка подготовки) — стало 12, а по коду клиента их 17.\n",
    "Семь записей прятались за пробелами и регистром. Пять из них и сейчас не считаются полными повторами: они\n",
    "отличаются только записью даты в исходном столбце `signup_date` (`2023-07-21` и `21.07.2023`), который мы\n",
    "не меняли, а заменили столбцом `signup`. В итоговую таблицу возьмём только чистые столбцы."
   ],
   "id": "text-65"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 3. Один клиент — одна строка\n",
    "\n",
    "Сохраните в `clean` столбцы из списка `columns` таблицы `customers` без дубликатов, отсортированные по\n",
    "`customer_id`, с индексом с нуля.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`customers[columns].drop_duplicates()` — исходные столбцы `signup_date` и `bonus` в сравнение уже не попадут.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "Затем `.sort_values(\"customer_id\").reset_index(drop=True)`.\n",
    "\n",
    "</details>"
   ],
   "id": "dedupe-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "columns = [\"customer_id\", \"name\", \"city\", \"signup\", \"segment\", \"email\", \"points\"]\n",
    "clean = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "dedupe"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 3 — выполните после своего решения\n",
    "def test_clean():\n",
    "    \"clean — по одной строке на клиента\"\n",
    "    assert isinstance(clean, pd.DataFrame), f\"clean — это {type(clean).__name__}, а нужна таблица\"\n",
    "    assert list(clean.columns) == [\"customer_id\", \"name\", \"city\", \"signup\", \"segment\", \"email\", \"points\"], f\"столбцы сейчас {list(clean.columns)}, а нужны из списка columns\"\n",
    "    assert len(clean) != 257, \"в clean все 257 строк: дубликаты не удалены\"\n",
    "    assert len(clean) != 245, \"осталось 5 лишних строк: drop_duplicates вызван до отбора columns — сравнились и сырые signup_date и bonus\"\n",
    "    assert len(clean) == 240, f\"в clean {len(clean)} строк — должна остаться одна строка на клиента\"\n",
    "    assert clean[\"customer_id\"].nunique() == 240, \"в clean повторяются customer_id\"\n",
    "\n",
    "\n",
    "def test_order():\n",
    "    \"clean отсортирована по customer_id, индекс с нуля\"\n",
    "    ids = clean[\"customer_id\"].tolist()\n",
    "    assert ids == sorted(ids), \"clean должна быть отсортирована по customer_id\"\n",
    "    assert list(clean.index) == list(range(len(clean))), \"индекс clean должен идти с нуля подряд: сбросьте индекс после сортировки\"\n",
    "\n",
    "_check(test_clean, test_order, names=[\"clean\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "dedupe-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "columns = [\"customer_id\", \"name\", \"city\", \"signup\", \"segment\", \"email\", \"points\"]\n",
    "clean = customers[columns].drop_duplicates().sort_values(\"customer_id\").reset_index(drop=True)\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "dedupe-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 4. Проверка и ответ\n",
    "\n",
    "У магазина есть эталонный справочник клиентов — `data/customers.csv`. Сверим с ним результат:"
   ],
   "id": "text-78"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Совпадает ли очищенная таблица с эталоном\n",
    "reference = pd.read_csv(\"data/customers.csv\")\n",
    "print((clean[\"customer_id\"] == reference[\"customer_id\"]).all())\n",
    "print((clean[\"name\"] == reference[\"name\"]).all())\n",
    "print((clean[\"city\"] == reference[\"city\"]).all())\n",
    "print((clean[\"segment\"] == reference[\"segment\"]).all())"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Все четыре столбца совпали. В жизни эталона обычно нет — тогда проверяют по-другому: число строк, список\n",
    "значений в каждом текстовом столбце, минимум и максимум в числах и датах."
   ],
   "id": "text-84"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 4. Ответ маркетингу\n",
    "\n",
    "По таблице `clean` сохраните: в `by_city` — число клиентов в каждом городе, в `wholesale` — строки оптовых\n",
    "клиентов (сегмент «оптовый»), в `avg_points` — средний бонус.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`clean[\"city\"].value_counts()`, `clean[clean[\"segment\"] == \"оптовый\"]`, `clean[\"points\"].mean()`.\n",
    "\n",
    "</details>"
   ],
   "id": "report-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "by_city = ...\n",
    "wholesale = ...\n",
    "avg_points = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "report"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 4 — выполните после своего решения\n",
    "def test_city():\n",
    "    \"by_city — клиентов по городам\"\n",
    "    assert isinstance(by_city, pd.Series), f\"by_city — это {type(by_city).__name__}, а нужен результат value_counts()\"\n",
    "    assert by_city.sum() == 240, f\"в by_city всего {by_city.sum()} клиентов: считайте по таблице clean\"\n",
    "    assert len(by_city) == 5 and by_city[\"Москва\"] == 89, \"числа не те: нужны подсчёты по столбцу city таблицы clean\"\n",
    "\n",
    "\n",
    "def test_rest():\n",
    "    \"wholesale — оптовые клиенты, avg_points — средний бонус\"\n",
    "    assert isinstance(wholesale, pd.DataFrame), f\"wholesale — это {type(wholesale).__name__}, а нужна таблица\"\n",
    "    assert len(wholesale) == 26 and (wholesale[\"segment\"] == \"оптовый\").all(), f\"в wholesale {len(wholesale)} строк — нужны оптовые клиенты из clean\"\n",
    "    assert abs(avg_points - 862.1666667) < 1e-4, f\"avg_points = {avg_points!r} — это не средний бонус клиентов clean\"\n",
    "\n",
    "_check(test_city, test_rest, names=[\"by_city\",\"wholesale\",\"avg_points\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "report-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "by_city = clean[\"city\"].value_counts()\n",
    "wholesale = clean[clean[\"segment\"] == \"оптовый\"]\n",
    "avg_points = clean[\"points\"].mean()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "report-solution"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Итог\n",
    "print(\"строк в выгрузке:\", len(customers), \"→ клиентов:\", len(clean))\n",
    "print(\"без даты регистрации:\", clean[\"signup\"].isna().sum(), \"· без email:\", clean[\"email\"].isna().sum())\n",
    "print(\"оптовых:\", len(wholesale), \"· средний бонус:\", round(avg_points))\n",
    "print(by_city)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "summary"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Выводы"
   ],
   "id": "text-96"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*После решения всех упражнений урока:*\n",
    "\n",
    "Из 257 строк выгрузки получилось 240 клиентов. Если бы мы посчитали города по сырым данным, получили бы\n",
    "22 «города» и завышенное число клиентов; если бы суммировали баллы без очистки — получили бы ошибку или\n",
    "потеряли треть баллов."
   ],
   "id": "component-99"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Порядок, который стоит запомнить:\n",
    "\n",
    "1. Осмотреть: `info`, `isna().sum()`, `value_counts` по текстовым столбцам.\n",
    "2. Текст: пробелы, регистр, варианты написания.\n",
    "3. Типы: даты и числа — с проверкой, сколько значений не распозналось.\n",
    "4. Пропуски: решить для каждого столбца отдельно — оставить, заполнить или удалить.\n",
    "5. Дубликаты — после всего остального.\n",
    "6. Проверить результат."
   ],
   "id": "text-104"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Подробнее в справочнике:** [pandas/datetime](https://ds-edu-trainer.vercel.app/reference/pandas/datetime) · [pandas/dtypes](https://ds-edu-trainer.vercel.app/reference/pandas/dtypes) · [pandas/missing](https://ds-edu-trainer.vercel.app/reference/pandas/missing) · [pandas/duplicates](https://ds-edu-trainer.vercel.app/reference/pandas/duplicates)"
   ],
   "id": "more"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "colab": {
   "provenance": []
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
