Published on

Pandas SettingWithCopyWarning 근본 해결 7가지

Authors

서론

SettingWithCopyWarning은 Pandas를 쓰다 보면 거의 반드시 마주치는 경고입니다. 많은 글이 “.loc 쓰면 된다” 정도로 끝나지만, 실제 현장에서는 필터링/정렬/체인 인덱싱/함수 체이닝이 섞이면서 경고가 다시 튀어나오고, 더 심각하게는 값이 일부만 반영되거나 반영되지 않는 침묵 오류로 이어지기도 합니다.

핵심은 간단합니다.

  • Pandas는 어떤 연산의 결과가 원본의 뷰(view) 인지, 독립된 복사(copy) 인지 상황에 따라 달라집니다.
  • 뷰인지 복사인지 애매한 객체에 대입이 발생하면, Pandas는 “원본에 쓰려는 건지, 사본에 쓰려는 건지” 확신하지 못해 경고를 냅니다.

이 글에서는 경고를 무시/비활성화하지 않고, 코드 구조를 바꿔서 근본적으로 불확실성을 제거하는 7가지 방법을 예제와 함께 정리합니다. (성격은 다르지만, ‘경고를 숨기지 말고 원인을 추적/제거한다’는 관점은 Chrome Forced reflow 경고 원인·해결 7단계와도 통합니다.)


SettingWithCopyWarning이 나는 대표 패턴

가장 흔한 재현 코드는 아래입니다.

import pandas as pd

df = pd.DataFrame({
    "team": ["A", "A", "B"],
    "score": [10, 20, 30]
})

sub = df[df["team"] == "A"]  # 필터 결과가 view/copy 애매
sub["score"] = sub["score"] + 1  # SettingWithCopyWarning 가능

위 코드는 의도상 team == "A"인 행의 score를 올리고 싶지만, 실제로는 sub가 복사본이면 원본 df는 바뀌지 않습니다. Pandas는 이 불확실성을 경고로 알려줍니다.


1) 체인 인덱싱(chain indexing) 제거: 한 번에 .loc로 끝내기

가장 강력하고 범용적인 해결책입니다. “선택(행/열) + 대입”을 한 문장으로 합치면 경고가 사라지고 의도도 명확해집니다.

나쁜 예(체인 인덱싱)

sub = df[df["team"] == "A"]
sub["score"] = sub["score"] + 1

좋은 예(단일 .loc)

mask = df["team"] == "A"
df.loc[mask, "score"] = df.loc[mask, "score"] + 1

포인트

  • df.loc[mask, "score"]는 “원본 df의 해당 위치”를 명시합니다.
  • 중간 객체(sub)를 만들지 않으므로 view/copy 애매함이 없어집니다.

2) 슬라이스/필터 결과를 ‘독립 객체’로 쓸 거면 즉시 .copy()로 확정

원본을 수정할 의도가 전혀 없고, 필터 결과를 별도 데이터셋으로 다룰 거라면 처음부터 복사본임을 선언하세요.

sub = df.loc[df["team"] == "A"].copy()
sub["score"] = sub["score"] + 1  # 경고 없이 안전

언제 이게 정답인가?

  • sub를 이후 여러 단계 변환에 사용하고, 원본 df와 생명주기를 분리하고 싶을 때
  • 함수가 df를 받아 가공한 새 DataFrame을 반환해야 할 때

> 팁: .copy(deep=True)/deep=False 논쟁이 있지만, SettingWithCopyWarning의 관점에서는 “복사본임을 명시”하는 것 자체가 중요합니다.


3) df[...] = ... 대신 assign() + pipe()로 ‘대입 없는 파이프라인’ 만들기

경고는 “애매한 객체에 대한 대입”에서 자주 발생합니다. 함수형 스타일로 대입을 최소화하면 경고가 줄고, 테스트도 쉬워집니다.

import numpy as np

result = (
    df
    .assign(
        score_plus=lambda d: np.where(d["team"].eq("A"), d["score"] + 1, d["score"])
    )
)

기존 컬럼을 직접 덮어쓰고 싶다면:

result = (
    df
    .assign(
        score=lambda d: np.where(d["team"].eq("A"), d["score"] + 1, d["score"])
    )
)

포인트

  • assign은 새 DataFrame을 반환하므로, 중간 슬라이스에 대한 불확실한 대입이 사라집니다.
  • 복잡한 전처리 파이프라인에서 특히 유용합니다.

4) query()/eval()로 필터·계산을 표현하고, 최종 대입은 .loc로 고정

query()는 가독성이 좋지만, df.query(...)["col"] = ... 같은 체인 대입을 하면 다시 위험해집니다. 필터는 query, 대입은 loc로 분리하세요.

mask = df.eval('team == "A"')
df.loc[mask, "score"] = df.loc[mask, "score"] + 1

또는 query로 인덱스를 얻어 대입:

idx = df.query('team == "A"').index
df.loc[idx, "score"] = df.loc[idx, "score"] + 1

포인트

  • query 결과에 바로 대입하지 말고, 인덱스/마스크를 추출해 원본에 .loc로 대입합니다.

5) 정렬/중복제거/리샘플 후 ‘원본처럼 보이는 중간 결과’에 대입하지 않기

다음 같은 코드도 현장에서 자주 경고를 만듭니다.

sub = df.sort_values("score")
sub[sub["team"] == "A"]["score"] = 0  # 위험: 체인 + 중간객체

해결은 동일합니다. “최종 대입은 원본에 .loc 한 번” 원칙을 지키세요.

mask = df["team"].eq("A")
df.loc[mask, "score"] = 0

만약 정렬된 결과를 기준으로 일부만 수정해야 한다면, 정렬된 인덱스를 이용해 원본을 찍습니다.

sorted_idx = df.sort_values("score").index
# 예: 점수가 낮은 순으로 1개만 team A라면 0으로
low_idx = df.loc[sorted_idx].query('team == "A"').head(1).index

df.loc[low_idx, "score"] = 0

포인트

  • sort_values, drop_duplicates, reset_index 등은 “원본처럼 보이는” 새 객체를 만들기 쉬워 대입 실수가 잦습니다.

6) 그룹 연산은 transform/agg를 활용하고, 결과는 정렬된 인덱스로 병합/대입

그룹별 계산 후 일부 컬럼을 업데이트하려다 경고가 나는 경우가 많습니다.

안전한 패턴: transform으로 같은 길이 결과 만들기

# 팀별 평균 점수 대비 편차 컬럼 추가
result = df.assign(
    score_centered=df["score"] - df.groupby("team")["score"].transform("mean")
)

업데이트가 필요하면 .loc로 명시

mask = df["team"].eq("A")
df.loc[mask, "score"] = (
    df.loc[mask, "score"] - df.groupby("team")["score"].transform("mean").loc[mask]
)

포인트

  • groupby().apply()는 반환 형태가 유동적이라 인덱스 정합성이 깨지기 쉽습니다.
  • 가능하면 transform으로 “원본과 동일 길이”를 유지하면 대입이 단순해집니다.

7) 경고를 ‘끄지 말고’, 테스트/검증으로 원본 반영 여부를 확인하는 습관 만들기

pd.options.mode.chained_assignment = None처럼 경고를 끄는 방법이 인터넷에 많이 돌아다니지만, 이는 근본 해결이 아닙니다. 오히려 “원본이 안 바뀌었는데도 조용히 넘어가는” 상황을 만들 수 있습니다.

대신 아래처럼 원본이 실제로 수정됐는지를 짧게 검증하는 습관이 좋습니다.

import pandas as pd

before = df.copy()
mask = df["team"].eq("A")
df.loc[mask, "score"] += 1

# 검증: team A의 score만 바뀌었는지 확인
changed = df["score"].ne(before["score"])
assert changed.equals(mask), "의도치 않은 행이 변경되었거나 변경이 누락됨"

데이터 파이프라인이 커질수록 “어디서 원본이 바뀌었는지/안 바뀌었는지” 추적이 어려워집니다. 경고를 숨기는 대신, 이런 검증을 통해 문제를 조기에 잡는 편이 운영 비용이 훨씬 낮습니다. 대규모 장애에서 원인을 추적하는 관점은 PostgreSQL autovacuum 지연으로 팽창·슬로우쿼리 잡기 같은 글에서 다루는 ‘관측/검증’의 중요성과도 연결됩니다.


실전 체크리스트: 경고를 없애는 게 아니라 ‘불확실성’을 없애라

아래 7가지만 기억하면 SettingWithCopyWarning은 대부분 사라집니다.

  1. 체인 인덱싱 금지: 선택+대입은 한 번에 .loc[rows, cols] = ...
  2. 슬라이스를 독립적으로 쓸 거면 즉시 .copy()
  3. 가능하면 assign()로 새 DF 생성(대입 최소화)
  4. query/eval은 필터 표현에만, 대입은 .loc
  5. 정렬/중복제거/리셋인덱스 후 중간 객체에 대입하지 말고 원본 인덱스로 대입
  6. 그룹 연산은 transform 우선, 인덱스 정합성 유지
  7. 경고를 끄지 말고 assert로 원본 반영 검증

마무리

SettingWithCopyWarning은 귀찮은 잡음이 아니라, Pandas가 “지금 코드가 데이터 무결성을 깨뜨릴 수 있다”고 알려주는 신호입니다. 해결의 본질은 .loc 자체가 아니라, 원본에 대한 쓰기(write)가 어떤 객체에 대해 일어나는지 명확히 만드는 것입니다.

전처리 코드가 길어질수록 “중간 결과를 변수에 담아 여기저기 수정”하는 스타일은 위험해집니다. 위 7가지 패턴(특히 .loc 단일 대입, .copy() 선언, assign 기반 파이프라인)을 팀 규칙으로 정하면 SettingWithCopyWarning은 물론, 데이터 품질 이슈까지 함께 줄어듭니다.