배터리 데이터셋과 데이터 함정

먼저 읽을 장 30.1 ML 연구자를 위한 배터리 기초15.4 데이터 분할과 누수 방지

배터리 수명 데이터는 비싸다. 셀 하나가 수명에 닿기까지 수 주에서 수 개월이 걸리고, 시험 채널과 온도 챔버는 한정되어 있다. 그래서 공개 데이터셋 하나에 들어 있는 셀은 대개 수십에서 수백 개다. 사이클 기록은 수만에서 수백만 줄이지만, 독립적인 표본은 셀의 수다. 이 차이를 잊으면 사이클 수만큼의 데이터를 가진 것처럼 착각하고, 사이클을 무작위로 섞어 나누는 누수를 만든다.

15부는 데이터 일반의 분할·누수·버전 관리를 다뤘다. 이 장은 그 원리를 배터리 데이터에 적용할 때 실제로 부딪히는 판단을 다룬다. 어떤 공개 데이터를 어떻게 비교하는가, 출처가 다른 데이터를 어떻게 합치는가, 무엇을 단위로 나누는가, 수명에 닿지 못한 셀과 잡음 섞인 용량 기록을 어떻게 label로 만드는가. 이 장의 수치는 모두 합성 데이터의 값이다.

학습 목표

  • 공개 배터리 데이터셋을 셀·화학·프로토콜·규모·라이선스 기준으로 비교한다
  • 셀·배치·프로토콜 단위로 분할하고 사이클 단위 분할의 누수를 정량적으로 보인다
  • 중도절단과 품질 문제(단위·부호·결측·용량 잡음)를 점검하고 label을 만든다

공개 데이터셋

왜 필요한가. 배터리 ML 논문의 결과는 거의 모두 몇 안 되는 공개 데이터셋 위에서 나온다. 데이터셋의 조건이 곧 결과의 적용 범위다. 한 데이터셋에서 얻은 결론을 "배터리 일반"으로 확장하기 전에, 그 데이터가 어떤 셀을 어떤 조건에서 쟀는지 알아야 한다.

비교의 축. 데이터셋을 고르거나 논문의 결과를 읽을 때 다음을 표로 정리한다.

축 확인할 것 왜 중요한가
셀 제조사·모델명, 형식(원통형·파우치·각형), 공칭 용량 같은 화학이라도 셀 모델이 다르면 열화 거동이 다르다
화학 양극·음극 재료 OCV 모양, 열화 기작, 전압 범위가 다르다(30.1장)
프로토콜 충전·방전 조건, 온도, RPT 주기 조건이 다양할수록 일반화를 시험할 수 있다
규모 셀 수, 수명까지 시험한 셀 수, 조건당 셀 수 독립 표본은 셀이다. 조건당 셀 수가 셀 간 변동을 추정할 수 있는지를 정한다
신호 샘플링 주기, 시계열 원자료인지 사이클 요약인지 쓸 수 있는 특징이 달라진다
라이선스 이용·재배포 조건 통합 데이터셋을 공개할 수 있는지를 정한다(15.5장)

대표 예. Severson 외(2019)는 상용 LFP/흑연 셀 124개를 72가지 고속 충전 조건으로 수명까지 사이클링한 데이터를 공개했다. 수명(공칭 용량의 80%까지의 사이클 수)은 약 150에서 2,300 사이클까지 퍼져 있다. 한 화학, 한 셀 모델, 한 시험 환경에서 충전 프로토콜만 바꾼 설계라서, 충전 조건에 따른 수명 변동을 연구하기에 좋고, 다른 화학이나 다른 사용 패턴으로의 일반화를 말하기에는 부족하다. 이 데이터를 쓴 결과에는 이 범위를 함께 적는다.

통합 스키마 데이터 허브. 여러 기관의 데이터를 공통 형식으로 모아 제공하는 저장소도 있다. 집필 시점 예시로, 미국 Sandia 국립연구소가 만든 Battery Archive(batteryarchive.org)가 여러 출처의 셀 사이클링 데이터를 표준화된 형식으로 모아 보여 준다. 이런 허브는 통합 작업을 크게 줄여 주지만, 원 데이터셋의 정의(SOH의 기준, 부호 규약, 사이클의 경계)가 변환 과정에서 어떻게 처리되었는지는 원 문서와 대조해 확인한다. 어떤 데이터셋이 있는지, 각각의 라이선스가 무엇인지는 빠르게 바뀌므로 사용 시점에 각 저장소에서 확인한다.

이질성

왜 필요한가. 데이터를 늘리려고 여러 데이터셋을 합치면, 출처마다 다른 관행이 섞인다. 단위와 부호처럼 바로잡을 수 있는 차이와, 장비·프로토콜처럼 바로잡을 수 없고 기록해야 하는 차이가 있다.

바로잡을 수 있는 차이. 단위(Ah와 mAh), 전류 부호 규약, 사이클 번호의 시작(0과 1), 사이클의 경계(충전으로 시작하는가 방전으로 시작하는가), 용량 대신 비율만 공개한 경우가 있다. 이런 차이는 출처별 변환 규칙을 메타데이터 표로 만들어 한 곳에서 적용한다. 변환 규칙을 코드 곳곳에 흩어 두면 어떤 출처에 무엇을 했는지 추적할 수 없다.

같은 셀을 세 출처가 다른 관행으로 기록했다고 하고, 하나의 스키마로 합친다.

import numpy as np
import pandas as pd

def summary(n=5, q1=3.01):                                      # 가상의 셀 하나의 사이클 요약
    k = np.arange(1, n + 1)
    return pd.DataFrame({"cycle": k, "q_dis": q1 - 0.04 * (k - 1), "i_dis": 6.0})

a = summary()                                                   # 출처 A: Ah, 방전 +, 사이클 1부터
b = summary().assign(q_dis=lambda d: d.q_dis * 1000,            # 출처 B: mAh, 방전 -, 사이클 0부터
                     i_dis=-6.0, cycle=lambda d: d.cycle - 1)
c = summary().assign(q_dis=lambda d: d.q_dis / 3.01)            # 출처 C: 첫 사이클 대비 비율만 공개
raw = pd.concat([a.assign(src="A"), b.assign(src="B"), c.assign(src="C")], ignore_index=True)
for src, d in raw.groupby("src"):
    print(f"출처 {src}: cycle {d.cycle.min()}–{d.cycle.max()}, q_dis {d.q_dis.iloc[0]:g}–{d.q_dis.iloc[-1]:g}, i_dis {d.i_dis.iloc[0]:+g}")

meta = pd.DataFrame({"unit": [1.0, 1e-3, 3.01], "sign": [1, -1, 1], "first": [1, 0, 1]},
                    index=["A", "B", "C"])                      # 출처별 변환 규칙: 문서·메타데이터에서
m = meta.loc[raw.src].reset_index(drop=True)
tidy = pd.DataFrame({"src": raw.src, "cycle": raw.cycle - m["first"] + 1,
                     "q_dis_ah": raw.q_dis * m.unit, "i_dis_a": raw.i_dis * m.sign})
tidy["soh_nom"] = tidy.q_dis_ah / 3.0                           # SOH 정의를 하나로: 공칭 3.0 Ah 기준
wide = tidy.pivot(index="cycle", columns="src", values="soh_nom")
print(wide.round(3).to_string())
print("세 출처가 일치:", np.allclose(wide.A, wide.B) and np.allclose(wide.A, wide.C),
      "/ 방전 전류가 모두 양수:", bool((tidy.i_dis_a > 0).all()))
출처 A: cycle 1–5, q_dis 3.01–2.85, i_dis +6
출처 B: cycle 0–4, q_dis 3010–2850, i_dis -6
출처 C: cycle 1–5, q_dis 1–0.946844, i_dis +6
src        A      B      C
cycle
1      1.003  1.003  1.003
2      0.990  0.990  0.990
3      0.977  0.977  0.977
4      0.963  0.963  0.963
5      0.950  0.950  0.950
세 출처가 일치: True / 방전 전류가 모두 양수: True

원자료의 숫자는 1000배, 부호, 사이클 번호가 모두 다르지만, 변환 규칙 표 하나를 적용하면 세 출처가 같은 SOH로 모인다. 출처 C처럼 비율만 공개한 데이터는 첫 사이클의 용량(3.01 Ah)이 메타데이터에 없으면 공칭 기준 SOH로 바꿀 수 없다. 이런 출처는 SOH 정의를 바꿀 수 없다는 사실을 기록하고, 다른 출처와 섞어 쓰지 않는 것이 맞을 수 있다.

기록해야 하는 차이. 시험 기관과 장비(전류·전압 측정 정확도), 온도 제어(챔버 공기 온도인가 셀 표면 온도인가), 샘플링 주기와 기록 조건, 프로토콜(같은 "1C 방전"이라도 컷오프와 휴지 시간이 다르다)은 변환으로 없앨 수 없다. 이런 변수는 통합 스키마에 열로 남겨, 분할과 평가에서 그룹 변수로 쓸 수 있게 한다. 데이터셋 이름 자체가 모델이 알아볼 수 있는 강력한 특징이 되는 일이 흔하다. 한 데이터셋은 모두 고속 충전, 다른 데이터셋은 모두 저속 충전이라면, 모델이 배운 "충전 속도의 효과"는 실제로는 기관 차이일 수 있다.

분할

왜 필요한가. 15.4장의 원리는 "분할의 단위는 주장의 범위가 정한다"였다. 배터리 데이터에서는 이 원리를 어기는 가장 흔한 방식이 정해져 있다. 사이클을 한 줄씩 무작위로 섞는 것이다.

셀이 독립 단위다. 같은 셀의 사이클들은 같은 제조 편차(초기 용량·저항), 같은 시험 채널, 같은 온도 이력을 공유한다. 사이클 단위로 섞으면 테스트 사이클의 바로 앞뒤 사이클이 학습에 들어간다. 모델은 수명과 무관한 "셀의 지문"으로 셀을 알아보고 그 셀의 label을 돌려줄 수 있다.

40개 셀의 처음 100 사이클로 셀의 수명을 예측한다. 특징은 사이클별 용량, 첫 사이클 대비 용량 변화, 저항, 사이클 번호다. 용량 감소 속도는 수명과 관계가 있지만, 초기 용량과 저항은 수명과 무관한 제조 편차다.

from sklearn.model_selection import KFold, PredefinedSplit, cross_val_predict
from sklearn.neighbors import KNeighborsRegressor
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

rng = np.random.default_rng(0)
n_cell, n_cyc = 40, 100
life = np.exp(rng.normal(np.log(900), 0.3, n_cell))             # (40,) 셀별 수명 [사이클]
q0 = rng.normal(3.05, 0.02, n_cell)                             # (40,) 초기 용량 [Ah]: 수명과 무관한 제조 편차
r0 = rng.normal(20.0, 1.0, n_cell)                              # (40,) 초기 저항 [mOhm]: 수명과 무관
k = np.arange(1, n_cyc + 1)
cap = q0[:, None] - (150 / life)[:, None] * 1e-3 * k + rng.normal(0, 0.003, (n_cell, n_cyc))
res = r0[:, None] + rng.normal(0, 0.2, (n_cell, n_cyc))         # (40, 100)
X = np.c_[cap.ravel(), (cap - cap[:, :1]).ravel(), res.ravel(), np.tile(k, n_cell)]  # (4000, 4)
y, cell = np.repeat(life, n_cyc), np.repeat(np.arange(n_cell), n_cyc)    # 사이클 한 줄 = 한 샘플
model = make_pipeline(StandardScaler(), KNeighborsRegressor(n_neighbors=5))
pred_cyc = cross_val_predict(model, X, y, cv=KFold(5, shuffle=True, random_state=0))
pred_cell = cross_val_predict(model, X, y, cv=PredefinedSplit(cell % 5))   # 한 셀의 사이클은 같은 fold
for name, p in [("사이클 단위 KFold", pred_cyc), ("셀 단위 5-fold", pred_cell)]:
    print(f"{name:16s} MAE {np.mean(np.abs(p - y)):6.1f} 사이클")
print(f"{'평균값 예측':16s} MAE {np.mean(np.abs(life.mean() - life)):6.1f} 사이클")
사이클 단위 KFold     MAE   58.4 사이클
셀 단위 5-fold      MAE  170.6 사이클
평균값 예측           MAE  168.7 사이클

사이클 단위 분할의 MAE 58 사이클은 인상적으로 보인다. 셀 단위로 나누면 171 사이클로, 모든 셀에 평균 수명을 예측하는 baseline(169)과 다를 바 없다. 이 모델은 새 셀에 대해 아무것도 배우지 못했고, 사이클 단위 분할의 점수는 같은 셀을 알아본 결과였다. 특징과 모델을 바꾸면 셀 단위 점수도 나아질 수 있다(초기 사이클에서 수명 정보를 뽑는 특징은 30.4장). 여기서 중요한 것은 두 점수 중 어느 것이 새 셀에 대한 추정인가다.

셀보다 큰 단위. 셀 단위 분할도 충분하지 않을 때가 있다.

주장 분할 단위 셀 단위 분할이 새는 정보
같은 조건의 새 셀 셀 (없음)
같은 셀 모델의 새 생산 lot 제조 lot·시험 배치 lot 공통의 제조 편차, 같은 시기의 장비 상태
처음 보는 충전 프로토콜 프로토콜 같은 프로토콜의 다른 셀이 그 조건의 수명 수준을 알려 준다
다른 기관·다른 셀 모델 데이터셋 기관 고유의 장비·관행

프로토콜마다 셀이 몇 개씩 있는 데이터에서 셀 단위로 나누면, 테스트 셀의 프로토콜은 거의 항상 학습에 나타난다. 이런 평가는 "이미 본 프로토콜에서 새 셀"에 대한 것이다. 처음 보는 프로토콜에 대한 주장이라면 프로토콜 단위로 묶어 나눈다. 셀 수가 적은 배터리 데이터에서는 fold마다 결과가 크게 흔들리므로 fold별 결과를 함께 보고한다.

censoring과 중단

왜 필요한가. 시험 기간은 정해져 있고, 수명이 긴 셀일수록 시험이 끝날 때까지 EOL에 닿지 못한다. 이런 셀의 수명은 "적어도 시험 길이 이상"이라는 것만 안다. 이것이 오른쪽 중도절단(right censoring)이다(16.8장).

처리에 따라 결론이 달라진다. 위의 40개 셀을 1000 사이클에서 시험을 끝냈다고 하자.

t_end = 1000                                                    # 시험 종료 사이클
observed = life <= t_end                                        # 수명이 관측된 셀
print(f"수명 관측 {observed.sum()}개, 중도절단 {(~observed).sum()}개")
print(f"전체 셀의 평균 수명(참)         {life.mean():6.0f} 사이클")
print(f"관측된 셀만의 평균 수명         {life[observed].mean():6.0f} 사이클")
print(f"중도절단을 1000으로 넣은 평균   {np.minimum(life, t_end).mean():6.0f} 사이클")
수명 관측 27개, 중도절단 13개
전체 셀의 평균 수명(참)            908 사이클
관측된 셀만의 평균 수명            790 사이클
중도절단을 1000으로 넣은 평균      858 사이클

중도절단된 13개 셀을 빼면 평균 수명은 908에서 790 사이클로 짧아진다. 빠지는 셀이 무작위가 아니라 수명이 긴 셀이기 때문이다. 중도절단 시점(1000)을 수명으로 넣어도 858로 여전히 짧다. 두 처리 모두 학습과 평가를 짧은 수명 쪽으로 기울인다. 16.8장의 생존분석은 고장 셀에는 밀도를, 중도절단 셀에는 생존확률을 likelihood에 넣어 이 정보를 편향 없이 쓴다(독립 중도절단 가정 아래에서).

배터리 데이터에서 중도절단의 다른 얼굴. 시험 종료 외에도 장비 고장, 안전 문제로 인한 시험 중단, 데이터 기록 실패로 셀이 중간에 사라진다. 이 중단이 셀의 상태와 관련 있다면(예: 발열이 심한 셀을 먼저 멈춤) 독립 중도절단 가정이 깨진다. 통합 스키마에는 셀마다 "시험이 어떻게 끝났는가"(EOL 도달, 시험 기간 종료, 장비 문제, 안전 중단)를 열로 남긴다.

품질 문제

왜 필요한가. 용량 곡선은 매끄러운 감소 곡선이 아니다. 측정 잡음, 온도 변동, 시험을 쉬었다 재개할 때의 일시적 회복, 그리고 측정 조건에 따른 계통 차이가 섞인다. 이것을 그대로 label로 쓰면 label 자체가 틀린다.

점검 목록. 셀마다 다음을 확인하고 결과를 품질 보고서로 남긴다.

  • 단위·부호: 셀별 용량의 중앙값이 공칭 용량 근처인가(mAh·Ah 혼동), 정전류 구간의 dV/dtdV/dt 부호가 규약과 맞는가(30.1장)
  • 결측: 빠진 사이클 번호, 측정 실패로 NaN이거나 0인 용량, 시험 중단으로 생긴 시간 공백
  • 이상치: 앞뒤 사이클과 크게 다른 한두 사이클(장비 재시작, 잘못 끝난 스텝)
  • 계통 차이: 사이클링 용량과 RPT 용량의 차이, 온도와의 상관

용량 곡선의 잡음과 재보정. 가상의 셀에서 사이클링 중 용량이 표준 조건(RPT) 용량보다 계통적으로 낮고, 챔버 온도에 따라 주기적으로 흔들리며, 200 사이클마다 RPT를 위해 쉬고 나면 일시적으로 회복된다고 하자. 같은 기록에서 EOL(2.4 Ah) label을 네 가지로 만든다.

n = np.arange(1, 1301)
q_true = 3.05 - 2.2e-4 * n - 0.02 * np.exp((n - 1000) / 100)    # 표준 조건(RPT)에서의 용량 [Ah]
temp_c = 25 + 3 * np.sin(2 * np.pi * n / 7)                     # 챔버 온도의 주기적 변동
since_rpt = n % 200                                             # 200 사이클마다 RPT(긴 휴지)
q_cyc = (q_true - 0.12                                          # 고율 사이클링: RPT보다 낮게 측정
         + 0.004 * (temp_c - 25)                                # 온도가 높으면 용량이 조금 크다
         + 0.015 * np.exp(-since_rpt / 15)                      # 휴지 직후 일시적 회복
         + rng.normal(0, 0.006, n.size))                        # 측정 잡음
rpt_n = n[n % 200 == 0]
def first_below(q, thr):
    return n[np.argmax(q < thr)] if np.any(q < thr) else None
thr = 0.8 * 3.0
smooth = pd.Series(q_cyc).rolling(15, center=True, min_periods=1).median().to_numpy()
print(f"참 EOL (RPT 용량, 매 사이클)         {first_below(q_true, thr)}")
print(f"사이클링 용량, 원자료 첫 교차        {first_below(q_cyc, thr)}")
print(f"사이클링 용량, 이동 중앙값 첫 교차   {first_below(smooth, thr)}")
offset = np.median(q_true[rpt_n - 1] - smooth[rpt_n - 1])        # RPT 시점의 (RPT - 사이클링) 차이
print(f"RPT로 재보정한 사이클링 용량 교차    {first_below(smooth + offset, thr)}  (보정량 {offset * 1e3:+.0f} mAh)")
참 EOL (RPT 용량, 매 사이클)         1291
사이클링 용량, 원자료 첫 교차        1252
사이클링 용량, 이동 중앙값 첫 교차   1256
RPT로 재보정한 사이클링 용량 교차    1289  (보정량 +112 mAh)

원자료의 첫 교차(1252)와 이동 중앙값(1256)은 모두 참 EOL(1291)보다 35–40 사이클 이르다. 평활은 무작위 잡음을 누르지만 고율 사이클링 용량이 RPT보다 0.1 Ah 남짓 낮은 계통 차이는 그대로 남기 때문이다. RPT 시점에서 두 용량의 차이를 재 사이클링 용량을 보정하면 1289로 참값에 가까워진다. 이 셀은 knee 근처에서 용량이 빠르게 떨어지므로 차이가 수십 사이클에 그쳤지만, knee 이전의 완만한 구간에서 기준선을 지나는 셀이라면 같은 계통 차이가 수백 사이클의 label 차이가 된다.

휴지 후 회복. 시험을 쉬었다 재개하면 용량이 일시적으로 올라갔다가 수십 사이클에 걸쳐 원래 추세로 돌아오는 현상이 흔히 관찰된다. 이 회복은 셀이 "건강해진" 것이 아니다. 시계열 모델이 이 튐을 열화 추세의 일부로 학습하거나, 수명 label을 정할 때 회복 직후의 값이 기준선 위로 다시 올라가 "두 번째 EOL"이 생기지 않게, label은 처음 교차하는 사이클로 정의하고 그 규칙을 문서화한다.

실습

잡음과 결측이 있는 용량 기록에서 수명 label을 만드는 함수를 작성한다. 후행 이동 중앙값으로 튐을 누르고, 결측 사이클과 NaN을 처리하고, 시험 끝까지 기준에 닿지 않은 셀은 중도절단으로 표시한다. 테스트가 바로 채점한다.

이어서 공개 데이터셋 두 개 이상을 골라 다음을 만든다. 데이터는 저장소에 넣지 않고 내려받기 스크립트만 둔다(15.5장).

  1. 통합 스키마: 셀 메타데이터 표(셀 id, 출처, 화학, 셀 모델, 공칭 용량, 프로토콜, 온도, 시험 종료 사유)와 사이클 요약 표(셀 id, 사이클, 방전 용량 [Ah], 방전 에너지 [Wh], 저항, 온도). 출처별 변환 규칙은 메타데이터 표 하나에 모은다.
  2. 분할: 셀 단위와, 데이터에 따라 프로토콜 단위·데이터셋 단위 분할을 만들고, 분할 파일을 버전과 함께 저장한다. 같은 모델을 사이클 단위 분할과 셀 단위 분할로 평가해 차이를 보고한다.
  3. 품질 보고서: 셀별 단위·부호 점검 결과, 결측 사이클 수, 이상치 수, 중도절단 여부, 사이클링 용량과 RPT 용량의 차이.

실패 사례

  • 같은 셀의 사이클이 학습·테스트에 섞이는 것. 셀의 지문으로 셀을 알아보는 누수가 생기고, 새 셀에서는 평균값 예측보다 나을 것이 없는 모델이 좋아 보인다. 독립 단위는 셀이다. 주장에 따라 프로토콜·배치·데이터셋 단위로 묶는다.
  • 데이터셋 간 SOH 정의 차이를 무시하는 것. 공칭 기준과 첫 측정 기준, 사이클링 용량과 RPT 용량을 섞으면 같은 열화가 다른 label이 된다. 통합 스키마에 SOH의 분자와 분모를 명시한다.
  • 중도절단 셀을 빼거나 시험 종료 시점을 수명으로 넣는 것. 둘 다 수명을 짧게 편향시킨다. 중도절단을 표시하고 생존분석의 방식으로 쓴다.
  • 사이클링 용량의 잡음만 평활하고 계통 차이는 두는 것. 평활은 계통 차이를 없애지 못한다. RPT로 보정하거나 label을 RPT 용량으로 정의한다.

통과 기준

  • 사용할 데이터셋마다 셀·화학·프로토콜·규모·라이선스를 표로 정리하고, 그 범위가 연구 주장을 뒷받침하는지 판단한다.
  • 통합 데이터셋의 스키마 문서, 셀(과 필요하면 프로토콜·데이터셋) 단위 분할 파일, 품질 보고서를 만든다.
  • 같은 모델을 사이클 단위와 셀 단위 분할로 평가해 누수의 크기를 보고하고, 중도절단을 표시한 수명 label을 만든다(연습문제 cycle-life-label 통과).

참고 문헌

  • Severson, K. A., Attia, P. M., Jin, N. 외 (2019). Data-driven prediction of battery cycle life before capacity degradation. Nature Energy, 4(5), 383–391. — 상용 LFP/흑연 셀 124개, 72가지 고속 충전 조건의 공개 수명 데이터