{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Мини-проект: грязная выгрузка клиентов, часть 1\n",
    "\n",
    "Выгрузка клиентов из CRM «как есть»: пробелы, разный регистр, сокращения городов. В первой части осматриваем её и чистим текст — коды, имена, города, сегменты и почту.\n",
    "\n",
    "Курс pandas · урок `pd-project-clean` · [урок на сайте](https://ds-edu-trainer.vercel.app/courses/pandas/project-clean).\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",
    "}\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": [
    "Отдел маркетинга «Кофейной лавки» выгрузил список клиентов из CRM и попросил простую вещь: сколько клиентов\n",
    "в каждом городе и сколько бонусных баллов у них в среднем. Но сначала выгрузку придётся привести в порядок.\n",
    "Новых методов в проекте нет — только приёмы этого модуля, собранные в правильном порядке. Проект идёт в двух\n",
    "уроках: в этом чистим текст, во [второй части](https://ds-edu-trainer.vercel.app/courses/pandas/project-clean-2) — даты, баллы и дубли,\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",
    "customers.head(8)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "load"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В первых же строках видно почти всё: «Екб» и «екатеринбург», пробелы вокруг имён, `НОВЫЙ` заглавными, баллы\n",
    "с пробелом (`2 400`), пустые поля."
   ],
   "id": "text-18"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Типы и пропуски\n",
    "customers.info()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "info"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Все семь столбцов текстовые — и дата, и баллы. Пропуски есть в дате регистрации, адресе почты и баллах."
   ],
   "id": "text-23"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Дубликаты, клиенты, города\n",
    "print(customers.duplicated().sum())\n",
    "print(customers[\"customer_id\"].nunique())\n",
    "customers[\"city\"].value_counts().head(8)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "survey"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Полных повторов 10, разных `customer_id` — 243, а городов — два десятка вместо пяти. Верить этим числам\n",
    "пока нельзя: из-за пробелов и регистра одно и то же значение считается несколько раз.\n",
    "\n",
    "План очистки: текст → даты → числа → дубликаты. Дубликаты — в конце: пока текст грязный, их не видно.\n",
    "\n",
    "## Шаг 1. Коды и имена"
   ],
   "id": "text-27"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 1. customer_id и name\n",
    "\n",
    "Приведите `customer_id` к виду `C001`: без пробелов, заглавная буква. Приведите `name` к виду «Сергей Ю.»:\n",
    "без пробелов по краям, один пробел между именем и инициалом, заглавные буквы только в начале слов. В `n_ids`\n",
    "сохраните число разных `customer_id` после очистки.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`customers[\"customer_id\"].str.strip().str.upper()`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "Для имён — `.str.strip().str.replace(\"  \", \" \").str.title()`: `title` делает заглавной первую букву каждого слова.\n",
    "\n",
    "</details>"
   ],
   "id": "ids-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# почистите столбцы customer_id и name\n",
    "n_ids = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "ids"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 1 — выполните после своего решения\n",
    "def test_ids():\n",
    "    \"customer_id — без пробелов, заглавная C\"\n",
    "    ids = customers[\"customer_id\"].tolist()\n",
    "    assert all(i == i.strip() for i in ids), \"в customer_id остались пробелы: запишите результат обратно в столбец\"\n",
    "    assert all(i == i.upper() for i in ids), \"в customer_id остались строчные буквы (c017)\"\n",
    "    assert n_ids != 243 and n_ids != 257, \"n_ids нужно считать после очистки\"\n",
    "    assert n_ids == 240, f\"n_ids = {n_ids!r} — это не число разных клиентов после очистки\"\n",
    "\n",
    "\n",
    "def test_names():\n",
    "    \"name — без лишних пробелов, вид «Имя Ф.»\"\n",
    "    names = customers[\"name\"].tolist()\n",
    "    assert all(n == n.strip() for n in names), \"в name остались пробелы по краям\"\n",
    "    assert not any(\"  \" in n for n in names), \"в name остались двойные пробелы внутри\"\n",
    "    assert not any(n == n.upper() for n in names), \"в name остались имена заглавными буквами (КСЕНИЯ О.)\"\n",
    "    assert names[:3] == [\"Сергей Ю.\", \"Сергей В.\", \"Максим С.\"], f\"первые имена сейчас {names[:3]}\"\n",
    "    assert customers[\"name\"].str.len().sum() == 2249, \"не все имена приведены к виду «Имя Ф.»\"\n",
    "\n",
    "_check(test_ids, test_names, names=[\"n_ids\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "ids-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "customers[\"customer_id\"] = customers[\"customer_id\"].str.strip().str.upper()\n",
    "customers[\"name\"] = customers[\"name\"].str.strip().str.replace(\"  \", \" \").str.title()\n",
    "n_ids = customers[\"customer_id\"].nunique()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "ids-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Разных кодов стало меньше: часть записей отличалась только регистром или пробелом.\n",
    "\n",
    "## Шаг 2. Города и сегменты\n",
    "\n",
    "После `strip` и `title` варианты регистра схлопнутся, но сокращения останутся:"
   ],
   "id": "text-43"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Города после strip и title\n",
    "sorted(customers[\"city\"].str.strip().str.title().unique())"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "city-before"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 2. city и segment\n",
    "\n",
    "Приведите `city` к пяти городам: уберите пробелы, выровняйте регистр и замените сокращения по словарю `short`.\n",
    "Приведите `segment` к трём значениям строчными буквами.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`customers[\"city\"].str.strip().str.title().replace(short)` — последний `replace` без `.str`: он заменяет значение целиком.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "`customers[\"segment\"].str.strip().str.lower()`.\n",
    "\n",
    "</details>"
   ],
   "id": "city-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "short = {\"Спб\": \"Санкт-Петербург\", \"С.-Петербург\": \"Санкт-Петербург\", \"Екб\": \"Екатеринбург\"}\n",
    "# почистите столбцы city и segment"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "city"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 2 — выполните после своего решения\n",
    "def test_city():\n",
    "    \"city — пять городов\"\n",
    "    cities = sorted(customers[\"city\"].unique())\n",
    "    assert not any(c != c.strip() for c in cities), \"в city остались пробелы по краям\"\n",
    "    assert \"москва\" not in cities and \"МОСКВА\" not in cities, \"в city остались варианты с другим регистром\"\n",
    "    assert \"Спб\" not in cities and \"Екб\" not in cities and \"С.-Петербург\" not in cities, \"в city остались сокращения: замените их словарём short — значение целиком, а не часть строки\"\n",
    "    assert cities == [\"Екатеринбург\", \"Казань\", \"Москва\", \"Новосибирск\", \"Санкт-Петербург\"], f\"в city сейчас значения {cities}\"\n",
    "\n",
    "\n",
    "def test_segment():\n",
    "    \"segment — три значения строчными буквами\"\n",
    "    segments = sorted(customers[\"segment\"].unique())\n",
    "    assert segments == [\"новый\", \"оптовый\", \"постоянный\"], f\"в segment сейчас значения {segments}, а нужны три, без пробелов и строчными буквами\"\n",
    "\n",
    "_check(test_city, test_segment)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "city-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "short = {\"Спб\": \"Санкт-Петербург\", \"С.-Петербург\": \"Санкт-Петербург\", \"Екб\": \"Екатеринбург\"}\n",
    "customers[\"city\"] = customers[\"city\"].str.strip().str.title().replace(short)\n",
    "customers[\"segment\"] = customers[\"segment\"].str.strip().str.lower()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "city-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 3. Почта"
   ],
   "id": "text-59"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 3. email\n",
    "\n",
    "Приведите `email` к строчным буквам. Пропуски оставьте пропусками. В `no_email` сохраните число строк без\n",
    "адреса.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Методы `.str` пропуски не трогают: `NaN` остаётся `NaN`.\n",
    "\n",
    "</details>"
   ],
   "id": "email-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# приведите столбец email к строчным буквам\n",
    "no_email = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "email"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 3 — выполните после своего решения\n",
    "def test_email():\n",
    "    \"email — строчными буквами, пропуски остались пропусками\"\n",
    "    known = customers[\"email\"].dropna().tolist()\n",
    "    assert all(e == e.lower() for e in known), \"в email остались заглавные буквы: запишите результат обратно в столбец\"\n",
    "    assert customers[\"email\"].isna().sum() != 0, \"пропуски в email заполнять не нужно: адрес неизвестен, и придумать его нельзя\"\n",
    "    assert customers[\"email\"].isna().sum() == 49, \"число пропусков в email изменилось, а должно остаться прежним\"\n",
    "    assert no_email == 49, f\"no_email = {no_email!r} — это не число строк без адреса\"\n",
    "\n",
    "_check(test_email, names=[\"no_email\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "email-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "customers[\"email\"] = customers[\"email\"].str.lower()\n",
    "no_email = customers[\"email\"].isna().sum()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "email-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Итог первой части"
   ],
   "id": "text-68"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Что изменила очистка текста\n",
    "print(customers[\"customer_id\"].nunique(), customers[\"city\"].nunique(), customers[\"segment\"].nunique())\n",
    "print(customers.duplicated().sum())\n",
    "customers[\"city\"].value_counts()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "text-done"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*После решения всех упражнений урока:*\n",
    "\n",
    "Кодов клиентов 240, городов пять, сегментов три — текстовые столбцы в порядке. Но отвечать маркетингу ещё\n",
    "рано: `value_counts` считает строки, а не клиентов, и дубли в этих числах пока есть. Полных повторов стало 12\n",
    "вместо 10 — часть дублей пряталась за пробелами и регистром. Удалять их будем в конце: дата регистрации\n",
    "и баллы всё ещё записаны текстом. Этим займёмся во [второй части](https://ds-edu-trainer.vercel.app/courses/pandas/project-clean-2)."
   ],
   "id": "component-73"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Подробнее в справочнике:** [pandas/strings](https://ds-edu-trainer.vercel.app/reference/pandas/strings) · [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
}
