Files
mini-lakehouse-lab/notebooks/03_raw_ingest_and_first_read.ipynb
T

48 KiB

Модуль 3. Raw-данные и первое чтение в Spark

В этом модуле мы разбираем полный первый цикл работы с учебным датасетом в Lakehouse:

  • создаём Spark-сессию для кластера и проверяем её настройки;
  • проверяем заранее скачанный локальный data bundle;
  • загружаем исходные файлы в raw-зону MinIO;
  • читаем raw-данные через Spark по s3a:// пути;
  • смотрим схему, типы, базовые метрики и простые аномалии;
  • фиксируем, почему raw-слой нужен как воспроизводимая неизменяемая точка входа.

После этого ноутбука ты должен уметь объяснить, почему raw-данные не надо «чинить на месте» и почему их не стоит скачивать прямо из ноутбука.

0. Перед стартом

Перед выполнением ноутбука:

  • подними стенд по START_HERE.md;
  • пройди notebooks/01_environment_and_smoke_test.ipynb;
  • пройди notebooks/02_lakehouse_mental_model.ipynb;
  • начни с нового ядра, чтобы настройки не зависели от предыдущих опытов;
  • заранее скачай data bundle в ./data/nyc_taxi по инструкции из START_HERE.md.

В классическом PostgreSQL / Greenplum данные часто уже лежат внутри системы. В Lakehouse путь более явный: сначала исходные файлы должны попасть в object storage, и только потом мы начинаем поверх них читать, профилировать и строить таблицы.

Классический DWH Lakehouse
Где стартует работа Данные уже внутри СУБД Исходные файлы сначала попадают в storage
Первая практическая задача SELECT из готовой таблицы Загрузить raw и прочитать raw
Что важно не сломать Таблицу / схему БД Неизменяемый raw-слой

0.1. Как настроен Spark в нашем стенде

Отведи на эту практику 20–30 минут. Сначала разберём готовый пример, затем ты заполнишь шаблон и проверишь подключение. Файлы Taxi понадобятся после практики.

SparkSession — объект spark, через который мы читаем DataFrame и выполняем SQL. Внутри него SparkContext связывает приложение с вычислительными ресурсами. В обычном ядре нашего ноутбука используется один работающий контекст.

Участник Что делает в этом стенде
Driver Работает в контейнере jupyter: выполняет Python-код ноутбука, строит план и раздаёт задания
Master Принимает подключение по spark://spark-master:7077 и выделяет ресурсы worker-ов
Executors Запускаются на spark-worker-1 и spark-worker-2, читают данные и выполняют задания

В режиме local[*] Spark выполняет задания локально, используя потоки на машине driver-а. В нашем курсе нужен кластер: успешный count() сам по себе ещё не доказывает, что подключение правильное.

Откуда берутся настройки

Для новой сессии стенд читает spark/spark-defaults.conf, смонтированный в Jupyter как /opt/spark/conf/spark-defaults.conf. В нём уже заданы master, каталог и доступ к MinIO.

При запуске приложения через spark-submit можно передать --master, --name и --conf. В коде похожие настройки задают методы builder-а: .master(...), .appName(...), .config(...). Обычно приоритет такой: код → параметры запуска → файл defaults. Но уже запущенный контекст не перестраивается от нового вызова builder-а, а параметры запуска driver-а надо передавать до его старта.

В нашем Jupyter дополнительные --master/--conf при запуске не передаются. Готовый пример ниже берёт master из файла, а имя задаёт в коде. Вне стенда понадобятся также библиотеки Iceberg/S3A/JDBC и конфигурация каталога: одного адреса master недостаточно. Пути и состав образа описаны в справочнике стенда.

In [ ]:
from pyspark.sql import SparkSession

spark = (
    SparkSession.builder
    .appName("module-03-session-demo")
    .getOrCreate()
)
spark.sparkContext.setLogLevel("ERROR")

print("Spark:", spark.version)
print("Master:", spark.sparkContext.master)
print("Приложение:", spark.sparkContext.appName)
assert spark.sparkContext.master == "spark://spark-master:7077", "Нужен Spark-кластер стенда"
print("Сумма 0…9:", spark.range(10).groupBy().sum("id").first()[0])

appName — имя приложения, видимое в Spark UI. getOrCreate() создаёт сессию, если её ещё нет, или возвращает существующую. .master(...) выбирает вычислитель; в демонстрации он не указан, поэтому используется общий файл.

Что уже подготовлено для Iceberg и MinIO

Настройка в spark-defaults.conf Назначение
spark.sql.extensions Добавляет команды Iceberg к Spark SQL
spark.sql.catalog.lakehouse Подключает Spark-каталог с именем lakehouse
spark.sql.catalog.lakehouse.catalog-impl и .uri Выбирает JDBC-каталог в PostgreSQL, где зарегистрированы таблицы
spark.sql.catalog.lakehouse.warehouse Задаёт место для файлов таблиц в MinIO: s3a://lakehouse/warehouse
spark.hadoop.fs.s3a.endpoint и .path.style.access Направляют S3A-запросы в MinIO внутри Docker-сети

В том же файле находятся настройки доступа к PostgreSQL и MinIO. Выведем только выбранные несекретные значения. SQL-настройки читаем через spark.conf, master и имя — через spark.sparkContext. При передаче настроек в Hadoop префикс spark.hadoop. снимается.

In [ ]:
for key in (
    "spark.sql.extensions",
    "spark.sql.catalog.lakehouse",
    "spark.sql.catalog.lakehouse.catalog-impl",
    "spark.sql.catalog.lakehouse.uri",
    "spark.sql.catalog.lakehouse.warehouse",
):
    print(f"{key} = {spark.conf.get(key)}")

hadoop_conf = spark.sparkContext._jsc.hadoopConfiguration()
for key in ("fs.s3a.endpoint", "fs.s3a.path.style.access"):
    print(f"{key} = {hadoop_conf.get(key)}")

Что можно изменить во время работы

spark.sql.session.timeZone задаёт часовой пояс SQL-сессии. У одной и той же секунды Unix-времени будет разное отображение в UTC и Москве. Это настройка интерпретации времени, без изменения файлов данных.

Сохраним исходное значение и восстановим его в finally: оно вернётся даже при ошибке внутри опыта. Для Spark 3.5.1 эта настройка изменяется в работающей сессии.

In [ ]:
timezone_key = "spark.sql.session.timeZone"
original_timezone = spark.conf.get(timezone_key)
assert spark.conf.isModifiable(timezone_key)
try:
    for timezone in ("UTC", "Europe/Moscow"):
        spark.conf.set(timezone_key, timezone)
        value = spark.sql("SELECT from_unixtime(0) AS displayed_time").first()[0]
        print(timezone, value)
finally:
    spark.conf.set(timezone_key, original_timezone)

assert spark.conf.get(timezone_key) == original_timezone
print("Исходный часовой пояс восстановлен:", original_timezone)

Для чего нужен новый запуск

Проверим appName: это имя приложения, которое запущено через SparkContext. Попробуем задать другое имя через builder и посмотрим на фактическое spark.sparkContext.appName.

Повторное выполнение ячейки не останавливает приложение. getOrCreate() использует ту же сессию и тот же контекст; это не способ переключиться на другой master или переименовать уже запущенное приложение.

In [ ]:
demo_context = spark.sparkContext
demo_name = demo_context.appName
same_spark = (
    SparkSession.builder
    .appName("module-03-raw-ingest-and-first-read")
    .getOrCreate()
)
assert same_spark is spark
assert same_spark.sparkContext is demo_context
assert same_spark.sparkContext.appName == demo_name
print("То же приложение:", same_spark.sparkContext.appName)

Теперь остановим демонстрационную сессию. В следующем задании новый контекст получит новое имя.

  • Повторный запуск ячейки сохраняет переменные Python и может использовать уже работающий контекст.
  • spark.stop() останавливает сессию и её контекст. Переменные Python остаются, но старые Spark DataFrame больше нельзя использовать. Файлы MinIO и записи каталога сохраняются.
  • Перезапуск ядра заново запускает Python и связанный с ним Spark driver. Переменные исчезают; нужно повторить импорты, создание сессии и чтение DataFrame. Данные в MinIO и каталоге от этого не удаляются.

Для смены master или конфигурации файла начинай с нового ядра. Остановка сессии без перезапуска ядра достаточна для нашего опыта с именем, но не заменяет новый процесс для всех стартовых настроек.

In [ ]:
spark.stop()

Самостоятельно: создай сессию и проверь подключение

Заполни три пропуска в следующей ячейке:

  1. Адрес кластера внутри Docker-сети.
  2. Имя приложения module-03-raw-ingest-and-first-read.
  3. Вызов builder-а с .master(...), .appName(...) и .getOrCreate().

Используй подготовленный каталог из spark-defaults.conf: его параметры не нужно копировать в код. Явный .master(...) здесь нужен, чтобы ты самостоятельно указал подключение; остальные ноутбуки берут тот же адрес из файла.

Шаблон остановится на assert, пока ты его не заполнишь. После заполнения выполни шаблон и ячейку проверки подключения дважды: имя и applicationId должны остаться теми же.

In [ ]:
from pyspark.sql import SparkSession

CLUSTER_MASTER = ...
APP_NAME = ...
assert CLUSTER_MASTER == "spark://spark-master:7077", "Впиши адрес кластера"
assert APP_NAME == "module-03-raw-ingest-and-first-read", "Впиши имя приложения"

# Замени ... цепочкой вызовов SparkSession.builder.
spark = ...
assert isinstance(spark, SparkSession), "Создай SparkSession через builder"
spark.sparkContext.setLogLevel("ERROR")
Контрольное решение — открой после своей попытки
from pyspark.sql import SparkSession

CLUSTER_MASTER = "spark://spark-master:7077"
APP_NAME = "module-03-raw-ingest-and-first-read"
spark = (
    SparkSession.builder
    .master(CLUSTER_MASTER)
    .appName(APP_NAME)
    .getOrCreate()
)
spark.sparkContext.setLogLevel("ERROR")
In [ ]:
assert spark.sparkContext.master == CLUSTER_MASTER
assert spark.sparkContext.appName == APP_NAME
print("Master:", spark.sparkContext.master)
print("Приложение:", spark.sparkContext.appName)
print("ID приложения:", spark.sparkContext.applicationId)

Проверь весь путь: Spark → каталог → MinIO

Одного правильного master недостаточно: проверим запись и чтение через каталог. Ячейка сама создаёт маленькую Iceberg-таблицу с числами 0 и 1, читает её и удаляет в finally. Таблицы модуля 2 не требуются.

У таблицы уникальное имя. DROP TABLE … PURGE удаляет именно её запись в каталоге, файлы данных и метаданных. В MinIO могут остаться пустые маркеры каталогов data/ и metadata/; это не данные таблицы. Пока ячейка работает, не перезапускай ядро: при принудительном завершении процесса finally не выполнится. Если это произошло, сохрани напечатанное имя для удаления этой таблицы после нового подключения.

Это только проверка подключения; создание рабочей таблицы поездок изучим в модуле 4.

In [ ]:
from uuid import uuid4

session_table = f"lakehouse.default.session_practice_{uuid4().hex}"
print("Временная таблица:", session_table)
try:
    spark.range(2).writeTo(session_table).using("iceberg").create()
    session_rows = [row.id for row in spark.table(session_table).orderBy("id").collect()]
    assert session_rows == [0, 1]
    print("Прочитано через Iceberg:", session_rows)
finally:
    spark.sql(f"DROP TABLE IF EXISTS {session_table} PURGE")

assert not spark.catalog.tableExists(session_table)
print("Временная таблица удалена")

Checkpoint настройки

Проверки выше должны подтвердить master spark://spark-master:7077, заданное имя приложения, строки [0, 1] и удаление временной таблицы. В опыте с часовым поясом исходное значение должно восстановиться.

Ответь своими словами:

  1. Какие настройки ты задал в коде, а какие предоставил стенд? Где их найти?
  2. Чем SparkSession отличается от SparkContext? Где работают driver и executors?
  3. Почему повторный getOrCreate() не сменил имя демонстрационного приложения, а после stop() оно изменилось?
  4. Что останется после перезапуска ядра и что потребуется выполнить заново?

Теперь, когда временная таблица удалена, перезапусти ядро. Выполни заполненный шаблон, проверку подключения и ячейку с временной таблицей. Они должны работать без демонстрации и без состояния прошлого ядра; applicationId будет новым. Импорт SparkSession уже есть в шаблоне.

После этой проверки продолжи ниже: подготовь переменные и MinIO-клиент, затем переходи к raw-данным. Сессию пока не останавливай.

0.2. Подготовка к raw-ingest

Сессия из самостоятельного задания продолжает работать. Создадим MinIO-клиент и переменные для исходного сценария модуля.

In [ ]:
from pathlib import Path
import re
from datetime import datetime

import boto3
import pandas as pd
from botocore.exceptions import ClientError
from pyspark.sql import SparkSession, functions as F

DATA_DIR = Path("/opt/data/nyc_taxi")
BUCKET_NAME = "lakehouse"
RAW_PREFIX = "raw/nyc_taxi/"
RAW_URI = f"s3a://{BUCKET_NAME}/{RAW_PREFIX}"
ZONE_LOOKUP_RAW_URI = f"{RAW_URI}taxi_zone_lookup.csv"

s3 = boto3.client(
    "s3",
    endpoint_url="http://minio:9000",
    aws_access_key_id="minioadmin",
    aws_secret_access_key="minioadmin",
)


def format_bytes(num_bytes: int) -> str:
    units = ["B", "KB", "MB", "GB", "TB"]
    value = float(num_bytes)
    for unit in units:
        if value < 1024 or unit == units[-1]:
            return f"{value:.1f} {unit}"
        value /= 1024


def object_exists(bucket: str, key: str) -> bool:
    try:
        s3.head_object(Bucket=bucket, Key=key)
        return True
    except ClientError as exc:
        error_code = exc.response.get("Error", {}).get("Code", "")
        if error_code in {"404", "NoSuchKey", "NotFound"}:
            return False
        raise


def list_objects(bucket: str, prefix: str) -> list[dict]:
    paginator = s3.get_paginator("list_objects_v2")
    objects = []
    for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
        objects.extend(page.get("Contents", []))
    return objects


def infer_month_bounds(paths: list[Path]) -> tuple[datetime, datetime]:
    months = []
    for path in paths:
        match = re.fullmatch(r"yellow_tripdata_(\d{4})-(\d{2})\.parquet", path.name)
        if match:
            year = int(match.group(1))
            month = int(match.group(2))
            months.append((year, month))

    assert months, "Не удалось определить месяцы из имён yellow_tripdata_*.parquet"

    start_year, start_month = min(months)
    end_year, end_month = max(months)

    expected_start = datetime(start_year, start_month, 1)
    if end_month == 12:
        expected_end = datetime(end_year + 1, 1, 1)
    else:
        expected_end = datetime(end_year, end_month + 1, 1)

    return expected_start, expected_end


print("Spark и MinIO-клиент готовы")
print(f"RAW URI: {RAW_URI}")

1. Проверка data bundle

Сначала убеждаемся, что нужные файлы уже лежат внутри Jupyter-контейнера по пути /opt/data/nyc_taxi.

Почему не скачиваем данные прямо здесь:

  • так модуль остаётся воспроизводимым и не зависит от сетевого доступа внутри ноутбука;
  • onboarding и подготовка окружения остаются в START_HERE.md, а практика в ноутбуке занимается только Lakehouse-операциями;
  • студент видит границу ответственности: сначала подготовить bundle, потом работать с raw-слоем.
In [ ]:
assert DATA_DIR.exists(), (
    "Каталог /opt/data/nyc_taxi не найден. "
    "Сначала скачай data bundle по инструкции из START_HERE.md и убедись, что ./data смонтирован в Jupyter как /opt/data."
)

bundle_files = sorted(path for path in DATA_DIR.iterdir() if path.is_file())
yellow_files = sorted(DATA_DIR.glob("yellow_tripdata_*.parquet"))
zone_lookup_file = DATA_DIR / "taxi_zone_lookup.csv"

assert yellow_files, (
    "Не найдены файлы yellow_tripdata_*.parquet в /opt/data/nyc_taxi. "
    "Скачай каноническое подмножество из START_HERE.md."
)
assert zone_lookup_file.exists(), (
    "Не найден taxi_zone_lookup.csv. Он нужен и для этого модуля, и для самостоятельного задания. "
    "Скачай его по инструкции из START_HERE.md."
)

bundle_report = pd.DataFrame(
    [
        {
            "file": path.name,
            "size_bytes": path.stat().st_size,
            "size_human": format_bytes(path.stat().st_size),
        }
        for path in bundle_files
    ]
).sort_values("file").reset_index(drop=True)

bundle_report

Набор файлов должен быть понятным и фиксированным. В этом модуле мы работаем не с «каким-то случайным parquet из интернета», а с заранее определённым учебным набором.

2. Загрузка в raw-зону MinIO

Raw-слой в Lakehouse похож на воспроизводимый входной stg-контур: мы сохраняем исходные файлы как есть, под оригинальными именами, не переписывая их содержимое.

Конвенция пути в этом курсе:

  • bucket: lakehouse
  • raw prefix: raw/nyc_taxi/
  • полный путь для чтения: s3a://lakehouse/raw/nyc_taxi/

Важно: raw-зона не смешивается с warehouse/, где потом будут жить Iceberg-таблицы.

Повторный запуск этой секции должен быть безопасным:

  • если объекта ещё нет в raw-зоне, мы его загружаем;
  • если объект уже есть и размер совпадает, мы пропускаем загрузку;
  • если объект уже есть, но размер отличается, это сигнал проблемы с данными или bundle, и мы падаем сразу, без перезаписи raw.
In [ ]:
upload_candidates = [*yellow_files, zone_lookup_file]
upload_report = []

for path in upload_candidates:
    object_key = f"{RAW_PREFIX}{path.name}"
    local_size = path.stat().st_size
    existed_before = object_exists(BUCKET_NAME, object_key)

    if existed_before:
        metadata = s3.head_object(Bucket=BUCKET_NAME, Key=object_key)
        remote_size = metadata["ContentLength"]
        if remote_size != local_size:
            raise ValueError(
                "Raw object already exists with different size: "
                f"{object_key} (local={local_size}, remote={remote_size}). "
                "Остановись и проверь data bundle вместо перезаписи raw-слоя."
            )
        action = "skipped_same_size"
    else:
        s3.upload_file(str(path), BUCKET_NAME, object_key)
        metadata = s3.head_object(Bucket=BUCKET_NAME, Key=object_key)
        remote_size = metadata["ContentLength"]
        action = "uploaded"

    upload_report.append(
        {
            "file": path.name,
            "raw_key": object_key,
            "action": action,
            "local_size_human": format_bytes(local_size),
            "remote_size_human": format_bytes(remote_size),
        }
    )

pd.DataFrame(upload_report)
In [ ]:
raw_objects = list_objects(BUCKET_NAME, RAW_PREFIX)

pd.DataFrame(
    [
        {
            "key": obj["Key"],
            "size_human": format_bytes(obj["Size"]),
        }
        for obj in raw_objects
    ]
)

Если объект уже существовал, мы не перезаписываем его без необходимости. Совпадающий размер означает, что повторный запуск секции идемпотентен. Несовпадающий размер считается конфликтом и останавливает сценарий до любых изменений в raw.

На этом шаге данные уже лежат в storage. Spark может читать их напрямую по s3a:// пути, а для Trino понадобится зарегистрированная таблица. До неё мы дойдём в Модулях 4 и 6.

3. Первое чтение raw PARQUET через Spark

Теперь читаем raw-файлы напрямую из MinIO по s3a:// пути. Это и есть первая важная практика Lakehouse: вычислитель (Spark) работает поверх файлов в object storage, а не только поверх локальной файловой системы или таблиц внутри одной СУБД.

Здесь важно различать два режима работы:

  • spark.read.parquet("s3a://...") читает raw-файлы напрямую по физическому пути;
  • spark.table("lakehouse....") читает уже зарегистрированную управляемую таблицу через каталог.

Проверочную Iceberg-таблицу из практики настройки мы уже удалили. Поездки пока лежат отдельными raw-файлами, поэтому читаем их через spark.read.parquet(...). Рабочую таблицу поездок создадим в Модуле 4.

In [ ]:
single_file_uri = f"{RAW_URI}yellow_tripdata_2024-01.parquet"
single_month_df = spark.read.parquet(single_file_uri)

print(f"Читаем один raw-файл: {single_file_uri}")
print(f"Строк в одном файле: {single_month_df.count():,}")
In [ ]:
yellow_df = spark.read.parquet(f"{RAW_URI}yellow_tripdata_*.parquet")
row_count = yellow_df.count()

print(f"Прочитано строк: {row_count:,}")
print(f"Количество parquet-файлов в наборе: {len(yellow_files)}")
In [ ]:
yellow_df.show(10, truncate=False)
In [ ]:
input_files = sorted(yellow_df.inputFiles())
pd.DataFrame({"input_file": input_files})

Обрати внимание: мы читаем сразу несколько файлов wildcard-путём. Это обычный рабочий сценарий для raw-слоя, где данные часто приходят партиями по датам, дням или месяцам.

В этом модуле мы намеренно не используем cache() для всего raw DataFrame. Для учебного профилирования важнее устойчиво прочитать исходные файлы, чем пытаться держать весь raw-срез в памяти executor-ов.

4. Схема, типы и null-значения

Следующий шаг после первого чтения: понять, какие колонки пришли, какие у них типы и где уже на raw-слое встречаются null.

Здесь важно не перепутать диагностику и очистку. В raw-слое мы наблюдаем и фиксируем реальность данных, а не исправляем её.

In [ ]:
yellow_df.printSchema()
In [ ]:
schema_df = pd.DataFrame(yellow_df.dtypes, columns=["column", "spark_type"])
print(f"Количество колонок: {len(yellow_df.columns)}")
schema_df
In [ ]:
null_counts_row = yellow_df.select(
    *[F.sum(F.col(column).isNull().cast("int")).alias(column) for column in yellow_df.columns]
).toPandas().iloc[0]

null_report = pd.DataFrame(
    {
        "column": yellow_df.columns,
        "null_count": [int(null_counts_row[column]) for column in yellow_df.columns],
    }
)
null_report["null_share"] = (null_report["null_count"] / row_count).round(6)
null_report.sort_values(["null_count", "column"], ascending=[False, True]).reset_index(drop=True)

Null в raw-данных сам по себе не является багом загрузки. Это нормальная часть профиля источника. Позже, на bronze и silver, мы будем принимать явные решения: какие поля очищать, что отбрасывать, а что нормализовать.

5. Базовые метрики и аномалии

Теперь быстро профилируем данные и ищем несколько очевидных проблемных паттернов:

  • отрицательный fare_amount;
  • нулевая дистанция при ненулевой сумме;
  • слишком большой total_amount;
  • pickup вне ожидаемого диапазона дат.

Диапазон дат будем выводить из имён файлов, а не хардкодить руками.

In [ ]:
numeric_columns = [
    column
    for column, spark_type in yellow_df.dtypes
    if spark_type.startswith(("tinyint", "smallint", "int", "bigint", "float", "double", "decimal"))
]

yellow_df.select(*numeric_columns).describe().toPandas()
In [ ]:
required_columns = {
    "fare_amount",
    "trip_distance",
    "total_amount",
    "tpep_pickup_datetime",
    "PULocationID",
    "DOLocationID",
}
missing_columns = sorted(required_columns.difference(yellow_df.columns))
assert not missing_columns, (
    "В raw-схеме не хватает ожидаемых колонок: "
    f"{missing_columns}. Возможно, источник поменял формат, и модуль требует обновления."
)

expected_start, expected_end = infer_month_bounds(yellow_files)

anomaly_report = pd.DataFrame(
    [
        {
            "check": "fare_amount < 0",
            "count": yellow_df.filter(F.col("fare_amount") < 0).count(),
        },
        {
            "check": "trip_distance = 0 and total_amount != 0",
            "count": yellow_df.filter(
                (F.col("trip_distance") == 0) & (F.col("total_amount") != 0)
            ).count(),
        },
        {
            "check": "total_amount > 1000",
            "count": yellow_df.filter(F.col("total_amount") > 1000).count(),
        },
        {
            "check": f"pickup outside [{expected_start.date()}, {expected_end.date()})",
            "count": yellow_df.filter(
                (F.col("tpep_pickup_datetime") < F.lit(expected_start))
                | (F.col("tpep_pickup_datetime") >= F.lit(expected_end))
            ).count(),
        },
    ]
)

anomaly_report
In [ ]:
suspicious_rows = yellow_df.filter(
    (F.col("fare_amount") < 0)
    | ((F.col("trip_distance") == 0) & (F.col("total_amount") != 0))
    | (F.col("total_amount") > 1000)
    | (F.col("tpep_pickup_datetime") < F.lit(expected_start))
    | (F.col("tpep_pickup_datetime") >= F.lit(expected_end))
)

suspicious_rows.select(
    "tpep_pickup_datetime",
    "passenger_count",
    "trip_distance",
    "fare_amount",
    "total_amount",
    "PULocationID",
    "DOLocationID",
).show(20, truncate=False)

Реальные данные почти всегда содержат выбросы, странные записи и неоднозначные значения. Это не повод немедленно переписывать raw-файлы. Наоборот, raw нужен именно затем, чтобы исходный срез можно было перечитать и переработать заново с новыми правилами.

6. Самостоятельное задание

Используй raw-данные, которые уже лежат в MinIO:

  1. Прочитай taxi_zone_lookup.csv из raw-зоны через Spark.
  2. Выведи схему и первые 10 строк.
  3. Посчитай количество зон по Borough.
  4. Найди top-5 pickup-локаций в поездках и присоедини к ним названия зон.
  5. Найди ещё одну аномалию или интересный паттерн и коротко опиши его.

Подсказка: CSV-файл лежит по пути s3a://lakehouse/raw/nyc_taxi/taxi_zone_lookup.csv.

In [ ]:
# Ваш код: прочитай taxi_zone_lookup.csv из raw-зоны через Spark
In [ ]:
# Ваш код: выведи схему и первые 10 строк
In [ ]:
# Ваш код: посчитай количество зон по Borough и top-5 pickup locations с join на lookup
In [ ]:
# Ваш код: найди ещё одну аномалию или интересный паттерн в данных

7. Почему raw-слой важен

Raw-слой полезен не потому, что в нём удобно жить постоянно, а потому что он даёт устойчивую исходную точку. Если завтра поменяются правила очистки, дедупликации или нормализации, мы не обязаны заново скачивать источник и гадать, что именно уже успели «подправить». Мы просто перечитываем raw и перестраиваем downstream-слои.

Для инженера с опытом PostgreSQL / Greenplum полезна такая аналогия: raw похож на аккуратный внешний staging-контур, но в Lakehouse он обычно хранится в object storage как набор файлов, а не как таблица внутри одной СУБД.

Отдельно важно, что загрузка источника вынесена из ноутбука в onboarding. Это делает практику менее хрупкой: ноутбук не зависит от внешней сети, а набор данных остаётся одинаковым у всех студентов.

Наконец, raw не стоит чинить «на месте». Если в исходных данных есть null, отрицательные значения или странные даты, это сигнал для явных трансформаций в bronze и silver, а не повод тихо переписать входные файлы.

8. Checkpoint

Проверь себя:

  1. Можешь ли ты показать файлы raw-зоны в MinIO через код или MinIO Console?
  2. Сколько колонок в схеме Yellow Taxi и какие типы там встречаются?
  3. Какие колонки содержат null и почему это не обязательно баг загрузки?
  4. Зачем нужен raw-слой, если дальше всё равно будут трансформации?
  5. Почему мы не скачиваем датасет прямо из ноутбука?
  6. Что произойдёт, если источник завтра поменяет формат файлов?

9. Завершение

Мы намеренно не удаляем raw-данные из MinIO. В Модуле 4 они понадобятся для построения первого управляемого bronze-слоя на Iceberg.

In [ ]:
spark.stop()