pipeline이 처음 안정적으로 보였을 때, 나는 그것을 그대로 믿지 않았다. 그 직감은 도움이 됐다. 안정적인 output과 올바른 output은 다르다. 특히 데이터가 커질수록 작은 오류는 훨씬 쉽게 숨어든다.

위험한 실패는 요란한 crash가 아니었다. 요란한 crash는 실행을 멈추고 주의를 강제한다. 더 신경 쓰였던 것은 조용한 실패였다. nullable field를 너무 가볍게 해석하거나, terminal event를 잘못된 temporal direction에 붙이거나, schema가 바뀌었는데도 parquet file은 계속 만들어지거나, label은 그럴듯해 보이지만 training set을 망치는 경우였다.

이 단계는 data repair를 진짜 작업 이후의 cleanup이 아니라 system의 일부로 다루는 과정이었다. model output으로 decision을 만들려면 label integrity는 명시적인 engineering concern이 되어야 했다.

실제 error로서의 schema drift

orchestrator 쪽은 결국 grouped trace, synthetic trace, live Kubernetes snapshot을 ingest해야 했다. 그래서 schema validation은 예상보다 더 중요해졌다. 나는 drift를 이름 있는 문제로 다루는 validator를 작성했다. 나중에 Python이 애매한 KeyError나 TypeError로 어딘가에서 실패하게 두고 싶지 않았다.

def _parse_int(value: Any, *, field: str, row_index: int) -> int:
    try:
        return int(value)
    except (TypeError, ValueError) as exc:
        raise ValueError(
            f"Schema drift: row[{row_index}] field {field!r} must be integer-like, got {value!r}."
        ) from exc

def _validate_grouped_row(row: dict[str, Any], row_index: int) -> None:
    if "timestamp" not in row:
        raise ValueError(f"Schema drift: grouped row[{row_index}] missing timestamp key.")
    _parse_int(row["timestamp"], field="timestamp", row_index=row_index)

이 스타일은 코드를 조금 덜 예쁘게 만들었지만, 실패를 읽을 수 있게 만들었다. 긴 local experiment를 돌릴 때는 readable failure message가 elegance보다 중요했다.

계속 확인했던 label bug

가장 중요한 label check는 temporal check였다. usage window가 끝나기 전에 이미 발생한 terminal event 때문에 어떤 row가 positive가 되면 안 된다. 당연해 보이지만, dataset이 분리된 usage source와 event source에서 만들어질 때는 future knowledge를 feature row에 실수로 leak하기 쉽다.

repair는 time-to-terminal 계산을 눈에 보이게 유지하고, window end 이전에 이미 발생한 terminal event를 위한 별도 flag를 보존하는 방식이었다.

(pl.col("is_failure_terminal_event")
 & pl.col("time_to_terminal_event_us").is_not_null()
 & (pl.col("time_to_terminal_event_us") >= 0)
 & (pl.col("time_to_terminal_event_us") <= horizon_us)
).alias("failure_within_horizon")

(pl.col("final_event_type").is_not_null()
 & (pl.col("last_event_time") < pl.col("end_time"))
).alias("terminal_event_before_window_end")

두 번째 flag가 training target이 아니라는 이유만으로 지우고 싶지 않았다. debugging 중에는 terminal state가 수상한 방식으로 row에 붙어 있는 시점을 볼 수 있게 해줘서 유용했다.

repaired code만이 아니라 repair report

이 단계에서 바꾼 또 다른 것은 progress를 기록하는 방식이었다. script만 고치고 넘어가면 왜 그 repair가 존재하는지 잊어버릴 게 뻔했다. repository의 report는 가벼운 lab notebook이 되었다. 무엇이 깨졌는지, 무엇을 수리했는지, 무엇이 여전히 위험한지, 다음 실행 후 어떤 generated artifact가 기대되는지를 적어두는 곳이었다.

이 습관은 나중에 프로젝트가 여러 track으로 갈라졌을 때 도움이 됐다. baseline forecaster, advanced XGBoost, orchestrator stack, Optuna/Ray tuning, local dual-cluster comparison, dashboard work가 따로 움직였다. progress report가 없었다면 그 track들이 서로 흐릿하게 섞였을 것이다.

repair loop는 느렸지만 필요했다

가장 재미없는 부분은 repair 후 작업을 다시 돌리는 것이었다. schema fix는 regenerated parquet을 강제할 수 있었다. label fix는 retraining을 강제할 수 있었다. feature change는 오래된 metric을 더 이상 비교할 수 없게 만들 수 있었다. 느리게 느껴졌지만, 썩은 label definition 위에 dashboard를 쌓는 것보다는 나았다.

이 시기에는 단순한 규칙이 있었다. 어떤 row가 무엇을 의미하는지 설명할 수 없다면, 그 row로 학습하고 싶지 않았다. 조금 극적으로 들리지만, 프로젝트를 현실에 붙들어 둔 규칙이었다. 나중에 dashboard에서 risk, queue length, decision, reward trace를 보게 되었을 때, 그 값들이 이미 한 번 씨름해 둔 contract에서 나온다는 것을 알고 있었다.

repair 이후 달라진 것

repair pass가 끝난 뒤에는 advanced modeling으로 넘어가는 것이 조금 더 편해졌다. target label은 temporal meaning이 더 명확해졌고, grouped trace ingestion에는 더 엄격한 validation이 생겼고, artifact layout은 덜 모호해졌다. 여전히 문제가 생길 거라고 예상했지만, 프로젝트가 더 이상 희망과 print statement만으로 버티는 상태는 아니었다.

또한 이 지점에서 프로젝트는 one-off classifier라기보다 systems experiment처럼 느껴지기 시작했다. 중심은 model이 아니라 data contract였다.

repair pipeline은 script를 통과시키는 것 이상의 역할을 했다. 프로젝트의 trust boundary를 바꿨다. 이 지점부터 성공한 run은 data만 만드는 것이 아니라, 그 data를 사용해도 되는 이유까지 함께 남겨야 했다.