{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Дубликаты\n",
    "\n",
    "Найти и удалить повторы: полные и по ключевым столбцам; какую запись оставить; почему слишком грубый ключ удаляет лишнее, а грязный текст прячет дубликаты; reset_index после удаления строк.\n",
    "\n",
    "Курс pandas · урок `pd-duplicates` · [урок на сайте](https://ds-edu-trainer.vercel.app/courses/pandas/duplicates).\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",
    "    \"returns_raw.csv\": \"\"\"\n",
    "eNqtml+SHDUSxt85RR9A3a1P/3WLjr0A4cB+4MXeHcYP+2bwARgIvEHAeg3hA+ysgwEHXuMrqG+08aW6q1RD9yDt8GJ3uLJSysyf\n",
    "MlNZvnh0+fTi8cefPlRPLh4+uuCPv148efj0k0v+vKhPHz64fKT+9vTB48tPL/+uLh49+OzJ44/+ojUUNP/cISnEjcbGaOOVUeU/\n",
    "+2fluvxKIUMhaLVDUHy81lgjK6jyofxW3uyflZvyc/mp3JT35Xq1f14+8E0+Kr+Wa2qwosGonYYyaJf5UG7k/Z/2z8t/y3V5R3En\n",
    "4knttJ0X9Mqr8r7crGTV97K9N+Vtebf/fP/limvtn+8/5+uerxundjop46bV0Pl64Os2qB1/+fb1D/tn5W15U27KL+VdebsSffsv\n",
    "ym/7L+Sv8qZc759RR6QOp9VOZwXT6nh1yuBE8VDFJ4PDiIczNcSlBoMBDdDUkGz12lFDpIYZBQgvmYE0sxDt4kPhJKdGg1lrt4QJ\n",
    "RAHIaqfjLISqwTJyzpkm8O5A2im3Be4mJN+IhzVwa8tuWtBLKMwczhM6wS3A6IUR0DTiVXlRfigvyjfldflnuSrfU5ywwKNxiGHk\n",
    "ljaTBgS71Jl6gaSkRgiyBvRkgel8nWgY7dWOZhxWN7L6V9MWDaNvhECvdNhoe+cJJVnaBDrV3dLZsyVDO0xiRsHhdbvWsca5i1Zj\n",
    "RUNueLdrPZKTjDtqaPYArewZi8mF1U7t4GZxP7IgUbHGSl45aqAd/RqiaMjCO/xW2+2dQSI31tmadY8LunMoG3Jio5eYWj0hUE+m\n",
    "lodx4W/+YwuRJRU2BSFNp6122+HMaYmGAytSnFKATmM6CIcTT0el00a75sh3edoSDmd5ZoyUENfk73+Vr8V5/yjflBflZblaldfl\n",
    "Vbkq38mj78rVeCqzhMP50Ij7mhYHzI6iIy6WNKni8cdmr6iCyLhQ7cbsOTOyDXLk4tJyk5UlR44cuRQrInaj/ezY3gUcREf6/QID\n",
    "OgQzqVV58jdwrio4O4mbZXh6a4ATonIWrrVvDT+5IPHx0pj52UZ3VjyIuKnJLLcnrycfZ3o0O9TkljY6jbc8jvR51MiaxaHr2YIj\n",
    "ed7Uc6/N0j9dZ9aRO2/pgyynftYwh8GTP+/S4pz197I8I57O8r7yjbzVfju4U29EQ5pakrtZ8ITPS2IOcih9k5g9ufLZHdze7qaz\n",
    "lHp/1NAchZraX3YEzhO9IIGbj8ZY1vFRVNilCncuV/ok4m3zEabaf0KcXAQJOZTFIuWcKIOBhIRgxKFWtw7t4birJw0EIOrDfchv\n",
    "dJjSbGd5CWQimigIHZe5fXELToTSQogu7jSFYERbL4fTGmM1KZCOKIclKo2Njk1f2YVniKLBNV1MOODZZUSS18PidTPSKAbyEwNE\n",
    "A/NKbI5fJC0xuZr74xTJMyxGiHhu6IhrfStHRaEj60XYqsUndRKFJNfzmo/ittmfk4e2OSuROHYiEIlAMrWu8C4ch64ekdFPVi9X\n",
    "TwNtVIyiwVaHLfnpZTASgST1OU+7MDjXNkfGO4XUEBMPJaLH5kQiUqzpFLnB5WS6SRDx2ojw4pVG614iLomXKeFzq9P293UvCSTs\n",
    "u4xWJkyQLPJFEliyXlhu9cBhTcJLjs3tPq3hRqwhMlm3jVliM2X7NUTRkKWZwaKZ6ayIicBktjITtmmNeDaEWcSzHEAyOhzCTGay\n",
    "HDPXajhF6OpP6tcyscneN6HKTEUjJyuTqhxcvdK6dt9dbaeT12PTOKe1xUCkMnnLMrQyCm6j853pNwtc2TcL5kP67Y1UVOBkT9DQ\n",
    "ywW7vZao4zifPOwC+lw+ypniLMMTjfn8VQVai7hfiHPgckYcFHfN5QV6rU1/igaF2fnP86VJwyl4wa6ZjfoCPJP7sww4HwY7rXo7\n",
    "3+q8vSPqYIChZfyW5bTkwfMJ9t3QkmKt0nEDXTXYLs6hBZrs5skPPZTO9LfgEwC1pwC20CMdKFh2AFOnjQjTZn23uRwFA8fm0rUb\n",
    "eFlerASMl+WHclW+Lt+W78uP5d8rweF1+ZGvEynYwx3LThs4zTc4aQCEQKswi7N/AVsagP0jf8V2K73GONHQtFzQ65EOFJzyAcEt\n",
    "AD+644/jwfEAEA6DRtPY170B8oPjnQKtQ3uAgPAkRTnd2sCJcsZROcVvOSws+gkOyAHDMo1G6NwdDLw1wNgaY203mD8A9biQ6QvG\n",
    "xnoDnl/vdyHnrDDMAcdpL8BOdKDagTdSGLZqyJMOOoKgcjcwsRlI4/BdoytG9C9Mam45OH6/6XJQlNd9szpqx8KHSR7Gud5Qdxrx\n",
    "HpEwOSzUG63cgPd4gYHVLVbydajTQWwLYNFMFXH8PHVy9n3fYTs4iIZF05xwQTeQNziGhkX9HqbTFmYoj3MGDRuaOjk5rHcDXjTU\n",
    "wMO1G+htd+7bMN03eXIuDifNvJZaYpriwIE3nIwc5zN36NO7seThcCY0h8Pckcg43Ibj6G7KI2asmnACDic9c1xadLpd4rQbLjbt\n",
    "H8zQpQycdcNF2+Tq4+fHUyZy1g0nN9Igk2pJtqG32eGgGzKdb1YbOjecfcNLbTGzhjDSY4MDcXj56puUyZOXz/QgHF7D+0OBiZPN\n",
    "/UNMcIANH2rG0W6p4sSKWcSnlBr4JX3s+gUOsyGD4QpjOPw/iSEduOfllc3ZCF4yzf6/56uQefd9RqeQeXf/ZRsy4L7HhP1/mlrO\n",
    "AQ==\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",
    "163 записи — посмотрим, сколько из них настоящих."
   ],
   "id": "text-10"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Журнал возвратов\n",
    "import pandas as pd\n",
    "\n",
    "returns = pd.read_csv(\"data/returns_raw.csv\")\n",
    "print(len(returns))\n",
    "returns.head(4)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "load"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Полные повторы\n",
    "\n",
    "Метод `duplicated` возвращает маску: `True` у строки, которая **целиком повторяет** одну из строк выше. На\n",
    "маленькой таблице это видно целиком:"
   ],
   "id": "text-16"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Кто уже встречался\n",
    "visits = pd.DataFrame({\n",
    "    \"client\": [\"Анна\", \"Олег\", \"Анна\", \"Анна\", \"Олег\"],\n",
    "    \"drink\": [\"латте\", \"эспрессо\", \"латте\", \"какао\", \"эспрессо\"],\n",
    "})\n",
    "visits[\"repeat\"] = visits.duplicated()\n",
    "visits"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "small"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Строка 2 повторяет строку 0, строка 4 — строку 1. Строка 3 — не повтор: клиент тот же, но напиток другой.\n",
    "Первое появление повтором не считается — отмечены только вторые и следующие.\n",
    "\n",
    "Сумма маски — сколько в таблице лишних строк:"
   ],
   "id": "text-23"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Лишних строк в маленькой таблице\n",
    "print(visits[\"repeat\"].sum())"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "count"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Чтобы увидеть повторы вместе с оригиналами, есть параметр `keep=False` — «не оставлять без отметки никого».\n",
    "После сортировки пары встают рядом:"
   ],
   "id": "text-30"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Одинаковые строки\n",
    "returns[returns.duplicated(keep=False)].sort_values(\"return_id\").head(6)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "show"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Удаляет повторы метод `drop_duplicates`: от каждой группы одинаковых строк остаётся первая."
   ],
   "id": "text-35"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 1. Убрать полные повторы\n",
    "\n",
    "Сохраните в `n_exact` число строк таблицы `returns`, которые полностью повторяют более ранние. В `unique_rows`\n",
    "сохраните таблицу без таких повторов.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Число — `returns.duplicated().sum()`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "Таблица — `returns.drop_duplicates()`.\n",
    "\n",
    "</details>"
   ],
   "id": "exact-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "n_exact = ...\n",
    "unique_rows = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "exact"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 1 — выполните после своего решения\n",
    "def test_count():\n",
    "    \"n_exact — сколько строк-повторов\"\n",
    "    assert n_exact != 14, \"14 — все строки, у которых есть двойник; лишних строк вдвое меньше\"\n",
    "    assert n_exact == 7, f\"n_exact = {n_exact!r} — это не число строк-повторов\"\n",
    "\n",
    "\n",
    "def test_unique():\n",
    "    \"unique_rows — таблица без полных повторов\"\n",
    "    assert isinstance(unique_rows, pd.DataFrame), f\"unique_rows — это {type(unique_rows).__name__}, а нужна таблица\"\n",
    "    assert len(unique_rows) != 149, \"удалены все строки, у которых был двойник, — вместе с оригиналами. Одну из каждой пары нужно оставить\"\n",
    "    assert len(unique_rows) == 156, f\"в unique_rows {len(unique_rows)} строк: удалять нужно только повторы\"\n",
    "    assert unique_rows.duplicated().sum() == 0, \"в unique_rows остались повторы\"\n",
    "\n",
    "\n",
    "def test_returns_kept():\n",
    "    \"таблица returns не изменилась\"\n",
    "    assert len(returns) == 163, \"таблица returns изменилась: результат drop_duplicates нужно сохранить в unique_rows. Выполните ячейку «Журнал возвратов» ещё раз\"\n",
    "\n",
    "_check(test_count, test_unique, test_returns_kept, names=[\"n_exact\",\"unique_rows\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "exact-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "n_exact = returns.duplicated().sum()\n",
    "unique_rows = returns.drop_duplicates()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "exact-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Повторы по ключу\n",
    "\n",
    "Осталось 156 записей, и все номера возвратов `return_id` разные. Но дубликат не обязан совпадать во всём.\n",
    "Один и тот же возврат могли оформить дважды — под двумя номерами. По смыслу возврат — это товар из заказа:\n",
    "пара «заказ, товар». Параметр `subset` велит сравнивать только указанные столбцы:"
   ],
   "id": "text-45"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Номера разные, а пары «заказ — товар» повторяются\n",
    "print(unique_rows[\"return_id\"].nunique(), len(unique_rows))\n",
    "print(unique_rows.duplicated(subset=[\"order_id\", \"product_id\"]).sum())"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "id-unique"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Шесть возвратов записаны дважды."
   ],
   "id": "text-53"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 2. Показать задвоенные возвраты\n",
    "\n",
    "Сохраните в `doubles` все записи таблицы `unique_rows`, у которых пара `order_id` и `product_id` встречается\n",
    "больше одного раза, — **обе** записи каждой пары. Отсортируйте `doubles` по `order_id` и `product_id`.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Маска — `unique_rows.duplicated(subset=[\"order_id\", \"product_id\"], keep=False)`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "Отберите строки по маске и вызовите `sort_values([\"order_id\", \"product_id\"])`.\n",
    "\n",
    "</details>"
   ],
   "id": "twice-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "doubles = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "twice"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 2 — выполните после своего решения\n",
    "def test_doubles():\n",
    "    \"doubles — возвраты, оформленные дважды: обе записи каждой пары\"\n",
    "    assert isinstance(doubles, pd.DataFrame), f\"doubles — это {type(doubles).__name__}, а нужна таблица\"\n",
    "    assert len(doubles) != 6, \"в doubles только вторые записи каждой пары, а нужны обе: вспомните параметр keep\"\n",
    "    assert len(doubles) == 12, f\"в doubles {len(doubles)} строк: нужны обе записи каждой пары\"\n",
    "    assert doubles[\"return_id\"].nunique() == 12, \"у записей в doubles должны быть разные return_id: искать повторы нужно в unique_rows по столбцам order_id и product_id\"\n",
    "    orders_list = doubles[\"order_id\"].tolist()\n",
    "    assert orders_list == sorted(orders_list), \"отсортируйте doubles по order_id и product_id — пары встанут рядом\"\n",
    "\n",
    "_check(test_doubles, names=[\"doubles\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "twice-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "doubles = unique_rows[unique_rows.duplicated(subset=[\"order_id\", \"product_id\"], keep=False)]\n",
    "doubles = doubles.sort_values([\"order_id\", \"product_id\"])\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "twice-solution"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Один возврат — два номера\n",
    "doubles.head(4)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "doubles-view"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Смотреть на найденные дубликаты глазами обязательно: только так видно, действительно ли это одна и та же\n",
    "запись.\n",
    "\n",
    "## Какую запись оставить\n",
    "\n",
    "У `drop_duplicates` тот же параметр `keep`: `\"first\"` (по умолчанию) оставляет первую запись из группы,\n",
    "`\"last\"` — последнюю, `False` — удаляет все."
   ],
   "id": "text-65"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Оставить первую или последнюю\n",
    "first = unique_rows.drop_duplicates(subset=[\"order_id\", \"product_id\"])\n",
    "last = unique_rows.drop_duplicates(subset=[\"order_id\", \"product_id\"], keep=\"last\")\n",
    "print(len(first), len(last))\n",
    "print(first[\"return_id\"].tail(3).tolist())\n",
    "print(last[\"return_id\"].tail(3).tolist())"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "keep"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Строк в обоих случаях 150, но записи разные. Какая правильнее, зависит от данных: если поздняя запись —\n",
    "исправление ранней, нужна последняя; если повтор — случайная вторая отправка, первая.\n",
    "\n",
    "Ключ нужно выбирать внимательно. Если сравнивать только по номеру заказа, удалятся и возвраты **разных**\n",
    "товаров из одного заказа — а это не дубликаты:"
   ],
   "id": "text-75"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Слишком грубый ключ\n",
    "len(unique_rows.drop_duplicates(subset=[\"order_id\"]))"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "too-wide"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "> **Типичная ошибка: ключ, который удаляет лишнее.**\n",
    ">\n",
    "> 146 строк вместо 150: четыре настоящих возврата потеряны, и ошибки нет. Прежде чем удалять по `subset`,\n",
    "> спросите себя: могут ли две **разные** записи совпасть по этим столбцам? Если да — ключ слишком грубый."
   ],
   "id": "component-84"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Вопрос.** Что выведет `print(pd.Series([1, 1, 2, 1]).duplicated().sum())`?\n",
    "\n",
    "- `1`\n",
    "- `2`\n",
    "- `3`\n",
    "\n",
    "<details><summary>Ответ</summary>\n",
    "\n",
    "`2`\n",
    "\n",
    "Первая единица — оригинал, вторая и третья — повторы. Двойка встречается один раз. Маска `[False, True, False, True]`, сумма 2.\n",
    "\n",
    "</details>"
   ],
   "id": "quiz-89"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Индекс после удаления\n",
    "\n",
    "Удалённые строки уносят свои метки — в индексе остаются дыры:"
   ],
   "id": "text-98"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Строк 150, а последняя метка — 156\n",
    "first.tail(3)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "reset"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Это не ошибка, но мешает: `loc[149]` может не найтись, а номер последней строки не равен числу строк.\n",
    "Метод `reset_index` нумерует строки заново. С параметром `drop=True` старые метки просто выбрасываются;\n",
    "без него они сохранились бы отдельным столбцом `index`."
   ],
   "id": "text-104"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Метки снова подряд\n",
    "first.reset_index(drop=True).tail(3)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "reset-index"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 3. Чистый журнал\n",
    "\n",
    "Сохраните в `clean` таблицу `unique_rows`, в которой от каждого задвоенного возврата (пара `order_id`,\n",
    "`product_id`) оставлена первая запись, а индекс идёт с нуля подряд. В `items_raw` сохраните сумму столбца\n",
    "`quantity` в исходной таблице `returns`, в `items_clean` — в `clean`.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`unique_rows.drop_duplicates(subset=[\"order_id\", \"product_id\"]).reset_index(drop=True)`.\n",
    "\n",
    "</details>"
   ],
   "id": "clean-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "clean = ...\n",
    "items_raw = ...\n",
    "items_clean = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "clean"
  },
  {
   "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 len(clean) != 146, \"осталось 146 строк: повторы искали только по order_id и удалили возвраты разных товаров из одного заказа. Нужны оба столбца: order_id и product_id\"\n",
    "    assert len(clean) == 150, f\"в clean {len(clean)} строк — проверьте, по каким столбцам ищете повторы\"\n",
    "    assert \"index\" not in clean.columns, \"в clean появился столбец index: вспомните параметр drop у reset_index\"\n",
    "    assert list(clean.index) == list(range(150)), \"индекс clean должен идти от 0 до 149 подряд\"\n",
    "    assert clean[\"return_id\"].iloc[-1] == \"R150\", \"из двух записей одного возврата нужно оставить первую (так делает drop_duplicates по умолчанию)\"\n",
    "\n",
    "\n",
    "def test_items():\n",
    "    \"items_raw и items_clean — возвращено штук до и после очистки\"\n",
    "    assert items_raw == 230, f\"items_raw = {items_raw!r}: нужна сумма quantity исходной таблицы returns\"\n",
    "    assert items_clean == 214, f\"items_clean = {items_clean!r}: нужна сумма quantity таблицы clean\"\n",
    "\n",
    "_check(test_clean, test_items, names=[\"clean\",\"items_raw\",\"items_clean\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "clean-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "clean = unique_rows.drop_duplicates(subset=[\"order_id\", \"product_id\"]).reset_index(drop=True)\n",
    "items_raw = returns[\"quantity\"].sum()\n",
    "items_clean = clean[\"quantity\"].sum()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "clean-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*После решения упражнения выше:*\n",
    "\n",
    "По сырому журналу возвращено 230 штук товара, по чистому — 214. Тринадцать лишних записей завышали итог\n",
    "на 7 %."
   ],
   "id": "component-119"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Сначала почистить, потом искать\n",
    "\n",
    "`duplicated` сравнивает значения буквально. Лишний пробел или другой регистр — и две записи об одном человеке\n",
    "считаются разными:"
   ],
   "id": "text-123"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Пять адресов, ни одного повтора?\n",
    "signups = pd.DataFrame({\n",
    "    \"email\": [\"anna@lavka.example\", \"Anna@lavka.example \", \"oleg@lavka.example\", \"OLEG@LAVKA.EXAMPLE\", \"maria@lavka.example\"],\n",
    "    \"city\": [\"Казань\", \"Казань\", \"Москва\", \"Москва\", \"Казань\"],\n",
    "})\n",
    "print(signups.duplicated().sum())\n",
    "print(signups[\"email\"].nunique())"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "hidden"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 4. Кто подписался\n",
    "\n",
    "В таблице `signups` — заявки на рассылку. Приведите столбец `email` к единому виду (без пробелов по краям,\n",
    "строчными буквами) и запишите обратно в `signups`. Затем сохраните в `people` таблицу без повторов с индексом\n",
    "с нуля, а в `n_people` — число людей.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`signups[\"email\"] = signups[\"email\"].str.strip().str.lower()`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "`signups.drop_duplicates().reset_index(drop=True)`.\n",
    "\n",
    "</details>"
   ],
   "id": "people-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# приведите signups[\"email\"] к единому виду\n",
    "people = ...\n",
    "n_people = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "people"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 4 — выполните после своего решения\n",
    "def test_email():\n",
    "    \"email — без пробелов, строчными буквами\"\n",
    "    emails = signups[\"email\"].tolist()\n",
    "    assert all(e == e.strip() for e in emails), \"в email остались пробелы по краям: запишите результат обратно в столбец\"\n",
    "    assert all(e == e.lower() for e in emails), \"в email остались заглавные буквы\"\n",
    "    assert len(signups) == 5, \"из signups строки удалять не нужно: результат drop_duplicates сохраните в people\"\n",
    "\n",
    "\n",
    "def test_people():\n",
    "    \"people — по одной строке на человека\"\n",
    "    assert isinstance(people, pd.DataFrame), f\"people — это {type(people).__name__}, а нужна таблица\"\n",
    "    assert len(people) != 5, \"в people остались все 5 строк: сначала почистите email, потом удаляйте дубликаты\"\n",
    "    assert len(people) == 3 and n_people == 3, f\"в people {len(people)} строк, n_people = {n_people!r}: сначала почистите email\"\n",
    "    assert sorted(people[\"email\"]) == [\"anna@lavka.example\", \"maria@lavka.example\", \"oleg@lavka.example\"], f\"в people адреса {sorted(people['email'])}: в каждом адресе — один человек\"\n",
    "    assert list(people.index) == [0, 1, 2], \"индекс people должен идти с нуля подряд\"\n",
    "\n",
    "_check(test_email, test_people, names=[\"people\",\"n_people\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "people-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "signups[\"email\"] = signups[\"email\"].str.strip().str.lower()\n",
    "people = signups.drop_duplicates().reset_index(drop=True)\n",
    "n_people = len(people)\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "people-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Порядок очистки поэтому всегда такой: сначала типы и текст, потом дубликаты.\n",
    "\n",
    "## Итоги\n",
    "\n",
    "- `duplicated()` — маска повторов (первое появление не отмечено); `keep=False` отмечает всех.\n",
    "- `drop_duplicates()` удаляет повторы; `subset=[...]` — сравнивать только эти столбцы; `keep` — какую\n",
    "  запись оставить.\n",
    "- Слишком грубый ключ удаляет настоящие записи; грязный текст прячет дубликаты.\n",
    "- После удаления строк — `reset_index(drop=True)`, если нужны метки подряд."
   ],
   "id": "text-139"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Подробнее в справочнике:** [pandas/duplicates](https://ds-edu-trainer.vercel.app/reference/pandas/duplicates) · [pandas/set-index](https://ds-edu-trainer.vercel.app/reference/pandas/set-index)"
   ],
   "id": "more"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "colab": {
   "provenance": []
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
