pandas로 생물 데이터 정제하기 — 발현 행렬·메타데이터 실전 패턴 (Python for Bio)

TL;DR — RNA-seq 카운트 행렬(genes × samples)을 받아 분석에 넣기 직전까지, "불러오기 → 샘플 메타데이터 결합 → 필터·집계 → wide↔long 변환 → 결측·중복 정리 → 저장"을 전부 pandas (파이썬 데이터 처리 라이브러리)로 처리합니다. 바이오 데이터 정제의 90%는 이 흐름 안에 있습니다.

핵심 도구는 두 자료구조입니다. 1차원 벡터인 Series (시리즈)와 2차원 표인 DataFrame (데이터프레임). read_csv로 표를 읽고, merge로 메타데이터를 붙이고, query·불리언 마스크로 거르고, groupby로 그룹 평균을 내고, melt/pivot으로 표 모양을 바꿉니다.

R의 dplyr·tidyr를 써 봤다면 1:1로 옮겨 탈 수 있습니다. filter()query(), mutate()assign(), group_by()+summarise()groupby()+agg(). 이 글 끝에 대응표를 정리했습니다.

🔗 관련 글  ·  R로 같은 작업을 한다면 dplyr·tidyr로 데이터 정리하기와 1:1로 비교됩니다  ·  공개 발현 데이터를 받는 법은 GEOquery로 공개 발현 데이터 받기  ·  카운트를 비교 가능한 값으로 바꾸는 RNA-seq 정규화

이 글에서 만들 것

전사체(transcriptome) 분석을 하다 보면 가장 먼저 마주치는 건 멋진 그래프가 아니라 모양이 어정쩡한 표입니다. 유전자가 행, 샘플이 열인 카운트 행렬(count matrix) 하나, 그리고 샘플마다 조건·배치·품질값이 적힌 메타데이터(metadata) 표 하나. 이 둘을 합치고 다듬어 "control 대비 treated에서 평균 발현이 어떻게 다른가" 같은 질문에 답할 수 있는 형태로 만드는 일이 분석의 절반입니다.

pandas (Wes McKinney, 2008~)는 파이썬에서 이 표 작업을 맡는 표준 라이브러리입니다. 엑셀의 표를 코드로 다룬다고 생각하면 가깝습니다. 다만 손으로 클릭하는 대신 한 줄씩 명령으로 적기 때문에, 같은 처리를 수백 개 샘플에 그대로 반복하고 재현할 수 있습니다.

이 글은 작은 발현 카운트 행렬을 직접 만들어, 실제 분석에서 거치는 순서대로 정제합니다. CSV·TSV로 불러오기, 샘플 메타데이터를 merge로 결합, 불리언·query로 필터, groupby로 그룹별 평균, melt/pivot으로 wide↔long 변환, 결측(NaN)·중복 처리, 정렬과 인덱스(index) 다루기, 그리고 저장까지입니다. 코드는 모두 pandas 3.0에서 실제로 돌려 확인한 것이고, 무작위 시드를 고정해 그대로 따라 하면 같은 결과가 나옵니다.

# 핵심 흐름 한눈에 (pandas)
import pandas as pd
df   = pd.read_csv("counts.csv", index_col=0)        # ① 카운트 행렬 불러오기
long = df.reset_index().melt(id_vars="gene_id",      # ② wide → long
                             var_name="sample_id", value_name="count")
long = long.merge(meta, on="sample_id", how="left")  # ③ 메타데이터 결합
sub  = long.query("group == 'treated' and count > 55")  # ④ 필터
grp  = long.groupby(["gene_id", "group"])["count"].mean()  # ⑤ 그룹 집계
wide = grp.reset_index().pivot(index="gene_id",       # ⑥ long → wide
                               columns="group", values="count")

1. 사전 준비 — 설치와 샘플 데이터

pandas는 pip이나 conda로 설치합니다. 수치 연산 백엔드인 NumPy (수치 배열 라이브러리)는 함께 깔립니다. 2026년 현재 안정 버전은 3.0.x 계열이며, 이 글의 코드도 3.0 기준입니다.

# pip로 설치 (가상환경 권장)
pip install pandas

# 또는 conda
conda install -c conda-forge pandas
import pandas as pd
import numpy as np

print(pd.__version__)
3.0.3

이제 설명용으로 작은 카운트 행렬을 만듭니다. 유전자 8개 × 샘플 6개, 값은 포아송(Poisson) 분포에서 뽑은 가짜 카운트입니다. 실제 분석이라면 이 행렬은 featureCounts·Salmon 같은 정량 도구의 출력이거나, 공개 데이터베이스(GEO 등)에서 받은 매트릭스가 됩니다.

import pandas as pd
import numpy as np

rng = np.random.default_rng(0)              # 재현성 고정 (시드 0)
genes   = [f"GENE{i:03d}" for i in range(1, 9)]   # GENE001 ~ GENE008
samples = ["S1", "S2", "S3", "S4", "S5", "S6"]

# 발현 카운트 행렬: 유전자(행) × 샘플(열)
counts = pd.DataFrame(
    rng.poisson(lam=50, size=(8, 6)).astype("int64"),
    index=genes, columns=samples,
)
counts.index.name = "gene_id"
counts
         S1  S2  S3  S4  S5  S6
gene_id
GENE001  53  32  52  51  57  59
GENE002  55  59  46  54  52  54
GENE003  54  41  50  50  63  51
GENE004  52  48  44  38  56  60
GENE005  46  49  44  48  39  46
GENE006  47  49  50  66  56  51
GENE007  48  40  60  50  45  49
GENE008  52  55  56  63  58  47

DataFrame은 2차원 표, 각 열은 Series (1차원)입니다. index는 행 이름(여기선 유전자 ID), columns는 열 이름(샘플 ID)입니다. 행렬을 CSV·TSV로 저장했다가 다시 읽어 보겠습니다.

# 저장: CSV와 TSV 두 가지
counts.to_csv("counts.csv")              # 쉼표 구분
counts.to_csv("counts.tsv", sep="\t")    # 탭 구분

# 불러오기: 첫 열(gene_id)을 인덱스로
df = pd.read_csv("counts.csv", index_col=0)
df.head(3)
         S1  S2  S3  S4  S5  S6
gene_id
GENE001  53  32  52  51  57  59
GENE002  55  59  46  54  52  54
GENE003  54  41  50  50  63  51

read_csvindex_col=0은 첫 열을 행 인덱스로 쓰라는 뜻입니다. 빼먹으면 gene_id가 그냥 데이터 열로 들어가 0,1,2… 정수 인덱스가 새로 붙습니다. TSV는 sep="\t"만 더해 같은 함수로 읽습니다. 자주 쓰는 인자는 아래와 같습니다.

인자 역할
sep / delimiter구분자. CSV는 기본값 ,, TSV는 "\t"
index_col어느 열을 행 인덱스로 쓸지 (0이면 첫 열)
usecols일부 열만 읽기 (["gene_id", "S1"]) — 큰 행렬에서 메모리 절약
dtype열별 자료형 지정 ({"gene_id": "str"})
na_valuesNA로 취급할 추가 문자열 (["NA", "-", "."])
comment주석 줄 건너뛰기 (comment="#")

이제 샘플 메타데이터를 만듭니다. 각 샘플의 처리군(group), 배치(batch), RNA 품질값(RIN)을 담습니다.

# 샘플 메타데이터: 행 = 샘플
meta = pd.DataFrame({
    "sample_id": ["S1", "S2", "S3", "S4", "S5", "S6"],
    "group":     ["control", "control", "control", "treated", "treated", "treated"],
    "batch":     ["b1", "b2", "b1", "b2", "b1", "b2"],
    "rin":       [9.1, 8.7, 9.4, 7.9, 8.2, 9.0],
}).set_index("sample_id")
meta
             group batch  rin
sample_id
S1         control    b1  9.1
S2         control    b2  8.7
S3         control    b1  9.4
S4         treated    b2  7.9
S5         treated    b1  8.2
S6         treated    b2  9.0
그림 1. wide↔long 변환 — melt로 행렬을 긴 표로 녹이고, pivot으로 다시 행렬로. 메타데이터 결합·그룹 집계는 long 형식에서 자연스럽다.

2. wide → long 변환 — melt

카운트 행렬은 사람이 보기엔 편하지만(wide 형식), 메타데이터를 붙이거나 그룹별로 묶어 집계하려면 long 형식이 훨씬 다루기 쉽습니다. long 형식은 한 행에 (유전자, 샘플, 값) 하나씩만 담는 모양입니다. tidyr의 pivot_longer()에 대응하는 melt로 바꿉니다.

# wide(8×6) → long(48×3)
long = (counts
        .reset_index()                       # gene_id를 일반 열로 꺼냄
        .melt(id_vars="gene_id",             # 그대로 둘 식별자 열
              var_name="sample_id",          # 녹인 열 이름 → sample_id
              value_name="count"))           # 녹인 값 이름 → count
long.head()
   gene_id sample_id  count
0  GENE001        S1     53
1  GENE002        S1     55
2  GENE003        S1     54
3  GENE004        S1     52
4  GENE005        S1     46

id_vars에 적은 열(gene_id)은 그대로 남고, 나머지 열(S1~S6)은 전부 sample_id라는 한 열로 "녹아" 들어갑니다. 8 × 6 행렬이 48행짜리 긴 표가 됐습니다. 이 모양이라야 다음 단계가 자연스럽습니다.

3. 메타데이터 결합 — merge

long 표의 sample_id를 열쇠(key)로 삼아 메타데이터를 붙입니다. dplyr의 left_join()에 해당하는 merge를 씁니다.

# sample_id 기준으로 메타데이터를 왼쪽 결합
long_m = long.merge(meta, on="sample_id", how="left")
long_m.head()
   gene_id sample_id  count    group batch  rin
0  GENE001        S1     53  control    b1  9.1
1  GENE002        S1     55  control    b1  9.1
2  GENE003        S1     54  control    b1  9.1
3  GENE004        S1     52  control    b1  9.1
4  GENE005        S1     46  control    b1  9.1

on="sample_id"는 두 표에서 이름이 같은 열을 짝지으라는 뜻입니다. metasample_id가 인덱스이므로, merge가 인덱스를 자동으로 열쇠로 인식해 붙여 줍니다. how는 결합 방식입니다 — "left"는 왼쪽 표(long)의 행을 모두 유지하고 오른쪽 정보를 채웁니다. 이제 각 카운트 옆에 그 샘플의 group·batch·RIN이 따라옵니다.

how 의미 (dplyr 대응)
"left"왼쪽 행 전부 유지 (left_join)
"right"오른쪽 행 전부 유지 (right_join)
"inner"양쪽에 다 있는 키만 (inner_join)
"outer"한쪽에만 있어도 모두 (full_join)

4. 필터 — 불리언 마스크와 query

조건에 맞는 행만 고르는 방법은 두 가지입니다. 불리언 마스크(boolean mask)와 query. dplyr의 filter()에 해당합니다.

# 방법 A: 불리언 마스크 — 조건식이 True/False Series를 만들고, .loc로 거름
mask = (long_m["group"] == "treated") & (long_m["count"] > 55)
long_m.loc[mask, ["gene_id", "sample_id", "count"]].head()
    gene_id sample_id  count
29  GENE006        S4     66
31  GENE008        S4     63
32  GENE001        S5     57
34  GENE003        S5     63
35  GENE004        S5     56
# 방법 B: query — 문자열로 조건을 적어 더 읽기 쉽게
q = long_m.query("group == 'treated' and count > 55")
# 두 결과는 동일

마스크 방식에서 조건이 여러 개면 각 조건을 괄호로 감싸고 &(그리고)·|(또는)로 잇습니다. 파이썬의 and/or가 아니라 &/|를 쓴다는 점, 그리고 우선순위 때문에 괄호가 반드시 필요하다는 점이 자주 걸리는 함정입니다. query는 조건을 문자열로 적어 한결 깔끔하고, 파이썬 변수는 @를 붙여, 공백이 든 열 이름은 백틱(`)으로 감싸 참조합니다.

# query 안에서 파이썬 변수(@)와 공백 든 열 이름(백틱) 쓰기
thresh = 55
df2 = long_m.rename(columns={"count": "raw count"})    # 공백 든 열 이름
df2.query("`raw count` > @thresh")                     # @thresh = 바깥 변수

5. 그룹 집계 — groupby

이제 "유전자별·그룹별 평균 발현"을 냅니다. dplyr의 group_by() + summarise()에 해당하는 groupby + 집계 함수입니다.

# 유전자 × 그룹마다 평균 카운트
grp = (long_m
       .groupby(["gene_id", "group"])["count"]   # 묶을 키 → 집계할 열
       .mean()
       .reset_index(name="mean_count"))          # 결과를 다시 표로
grp.head(6)
   gene_id    group  mean_count
0  GENE001  control   45.666667
1  GENE001  treated   55.666667
2  GENE002  control   53.333333
3  GENE002  treated   53.333333
4  GENE003  control   48.333333
5  GENE003  treated   54.666667

groupby(["gene_id", "group"])로 두 열의 조합마다 묶고, ["count"]로 집계 대상을 고른 뒤 .mean()을 적용합니다. 한 그룹에 여러 통계를 한꺼번에 내려면 agg에 함수 목록을 넘깁니다.

# 그룹별로 평균·표준편차·개수를 동시에
agg = long_m.groupby("group")["count"].agg(["mean", "std", "size"])
agg.round(2)
          mean   std  size
group
control  49.25  6.32    24
treated  52.62  7.08    24

6. long → wide 변환 — pivot

집계 결과를 다시 "유전자 × 그룹" 행렬로 되돌리면 한눈에 비교하기 좋습니다. melt의 반대, tidyr의 pivot_wider()에 해당하는 pivot입니다.

# long → wide: 행=유전자, 열=그룹, 값=평균 카운트
wide = grp.pivot(index="gene_id", columns="group", values="mean_count")
wide.round(2)
group    control  treated
gene_id
GENE001    45.67    55.67
GENE002    53.33    53.33
GENE003    48.33    54.67
GENE004    48.00    51.33
GENE005    46.33    44.33
GENE006    48.67    57.67
GENE007    49.33    48.00
GENE008    54.33    56.00

index는 행이 될 열, columns는 열이 될 열, values는 칸을 채울 값입니다. 이제 control과 treated의 평균을 유전자별로 나란히 볼 수 있습니다. 차이가 큰 유전자(GENE001·GENE006)가 눈에 들어옵니다.

그림 2. R(dplyr·tidyr) ↔ pandas 동사 대응 — 같은 정제 작업을 두 언어에서 어떻게 적는지 짝지어 한눈에.

7. 결측(NaN)·중복 처리

실제 데이터에는 빈칸과 중복이 섞여 있습니다. 메타데이터의 RIN 한 칸을 결측으로 만들어 다뤄 보겠습니다.

nm = meta.copy()
nm.loc["S3", "rin"] = np.nan      # 결측 하나 주입

nm.isna().sum()                   # 열별 결측 개수
group    0
batch    0
rin      1
dtype: int64
nm.dropna()                       # 결측이 있는 행 제거 → 5행
nm["rin"].fillna(nm["rin"].median())   # 중앙값으로 채우기 → S3 = 8.7

isna()로 어디가 비었는지 세고, dropna()로 결측 행을 버리거나 fillna()로 채웁니다. 발현 카운트는 보통 결측 행을 통째로 버리기보다 0으로 채우거나 그대로 두지만, 품질값 같은 메타데이터는 중앙값·평균으로 보간하는 편이 흔합니다. 중복은 duplicated()로 찾아 제거합니다.

dup = pd.concat([meta, meta.loc[["S1"]]])   # S1 행을 일부러 중복

dup.index.duplicated().any()                # 인덱스 중복 있나? → True
dup[~dup.index.duplicated(keep="first")]    # 첫 등장만 남김 → 6행

keep="first"는 중복 중 첫 번째만 남기라는 뜻입니다("last"도 가능). ~는 불리언을 뒤집는 부정 연산자라, "중복이 아닌 행만" 고르는 흔한 관용구입니다.

8. 정렬과 인덱스 — sort_values

마지막으로 평균 발현이 높은 유전자 순으로 정렬해 마무리합니다.

# 유전자별 평균 카운트를 내림차순 정렬
gene_means = (long_m
              .groupby("gene_id")["count"]
              .mean()
              .sort_values(ascending=False))
gene_means.round(2).head(4)
gene_id
GENE008    55.17
GENE002    53.33
GENE006    53.17
GENE003    51.50
Name: count, dtype: float64

sort_values(ascending=False)로 값 기준 내림차순, 인덱스(유전자 이름) 기준으로 정렬하려면 sort_index()를 씁니다. 정제가 끝난 표는 to_csv로 저장해 다음 분석으로 넘깁니다.

gene_means.to_csv("gene_means.csv")     # 결과 저장

R dplyr · tidyr 1:1 대응표

R에서 데이터 정제를 해 봤다면, 거의 모든 동사를 그대로 옮길 수 있습니다.

작업 R (dplyr / tidyr) pandas
행 필터filter(group == "treated")df.query("group == 'treated'")
열 선택select(gene_id, count)df[["gene_id", "count"]]
열 생성·변형mutate(cpm = count / total)df.assign(cpm=df["count"] / total)
그룹 집계group_by(group) %>% summarise(m = mean(count))df.groupby("group")["count"].mean()
정렬arrange(desc(count))df.sort_values("count", ascending=False)
결합left_join(meta, by = "sample_id")df.merge(meta, on="sample_id", how="left")
wide → longpivot_longer(...)df.melt(...)
long → widepivot_wider(...)df.pivot(...)
중복 제거distinct()df.drop_duplicates()
결측 제거drop_na()df.dropna()

가장 큰 차이는 인덱스(index)입니다. R 데이터프레임에는 행 이름이라는 개념이 약하지만, pandas는 행 인덱스를 1급 객체로 다룹니다. 그래서 set_index·reset_index로 "어떤 열을 인덱스로 둘지"를 자주 바꾸게 됩니다. 또 dplyr의 파이프(%>%/|>)처럼, pandas도 .으로 메서드를 줄줄이 이어 한 흐름으로 적을 수 있습니다.

9. 자주 발생하는 에러 & 오해

  • SettingWithCopyWarning — 슬라이스에 값을 쓸 때df[df.group == "treated"]["count"] = 999처럼 거른 결과(슬라이스)에 다시 대입하면, 원본이 바뀔지 사본만 바뀔지 모호합니다. pandas 2.x에서는 경고가 떴고, 3.0부터는 Copy-on-Write가 기본이라 경고 없이 원본은 그대로 두고 사본만 바뀝니다. 의도가 "원본 수정"이라면 한 번에 df.loc[df.group == "treated", "count"] = 999처럼 .loc로 행·열을 함께 지정하세요. 거른 표를 따로 쓸 거라면 .copy()를 붙여 의도를 분명히 합니다.
  • 숫자처럼 생긴 ID가 정수로 읽힘gene_id001·002라면 read_csv가 앞의 0을 떼고 정수 1·2로 읽어 버립니다. 유전자 ID·바코드·샘플명처럼 식별자는 dtype={"gene_id": "str"}로 문자열임을 명시하세요.
  • 문자열 열의 dtype가 object가 아니다 — pandas 3.0부터 문자열 열의 기본 자료형이 object에서 전용 str 타입으로 바뀌었습니다. df["group"].dtypestr로 나오는 게 정상입니다. 옛 코드에서 dtype == object로 문자열 열을 골랐다면 동작이 달라질 수 있으니 확인하세요.
  • merge 후 행 수가 늘어남 — 열쇠가 한쪽에서 중복이면 결합이 곱(cartesian) 형태로 불어납니다. 결합 전에 meta.index.is_uniquedf["sample_id"].duplicated().any()로 열쇠가 유일한지 점검하는 습관이 안전합니다.
  • 인덱스 정렬 때문에 값이 어긋남 — 인덱스가 다른 두 Series를 더하거나 나누면, pandas는 위치가 아니라 인덱스 이름으로 자동 정렬합니다. 의도와 달리 NaN이 생기거나 값이 섞이면 인덱스부터 의심하세요. 위치 기준으로 다루고 싶으면 .values(NumPy 배열)로 꺼내거나 .reset_index(drop=True)로 인덱스를 맞춥니다.
  • inplace=True 남발inplace는 코드를 짧게 만드는 듯하지만 메서드 체이닝을 끊고, 3.0에서는 대부분 성능 이점도 없습니다. df = df.fillna(0)처럼 새로 받는 편이 읽기에도 안전에도 낫습니다.
🎓 다음 학습
(R로 같은 작업) dplyr·tidyr로 데이터 정리하기 — 이 글의 동사들과 1:1로 대응하는 R 버전
(데이터 받기) GEOquery로 공개 발현 데이터 받기 — 정제 대상인 발현 행렬을 공개 DB에서 확보
(다음 단계) RNA-seq 정규화란? · RNA-seq 정량 단위 — TPM·RPKM·FPKM·CPM — 정제한 카운트를 비교 가능한 값으로
(개념 복습) 단일세포 RNA 시퀀싱(scRNA-seq)이란? — 더 큰 행렬을 다루는 분석으로
그림 3. pandas 데이터 정제 워크플로 — 불러오기부터 저장까지, 각 단계가 표를 어떻게 바꾸는지 한눈에.

References

  1. McKinney, W. (2010). Data Structures for Statistical Computing in Python. Proceedings of the 9th Python in Science Conference, 56–61. (pandas 원논문)
  2. The pandas development team. (2026). pandas documentation — v3.0.3. pandas.pydata.org/docs
  3. pandas. What's new in 3.0.0 — Copy-on-Write 기본화·문자열 dtype. whatsnew/v3.0.0
  4. Wickham, H. et al. (2019). Welcome to the tidyverse. Journal of Open Source Software, 4(43), 1686. (dplyr·tidyr 대응 참고)

Pipette & Pipeline · A bio portfolio journal

이 글을 쓴 사람 Yumingming

생명융합공학과 박사과정.
Microbiome · Cosmetics · RNA Therapeutics · Bioinformatics를 공부하며,
실험(Wet Lab)과 데이터(Dry Lab)를 잇는 글을 논문(article) 기반으로 씁니다.

About · 더 알아보기 →

⚕️ 이 글은 학습·정보 제공 목적이며, 의학적 진단·치료·조언을 대체하지 않습니다. 건강·질병·치료에 관한 결정은 반드시 의사 등 전문가와 상의하세요. 자세한 내용은 면책조항을 참고해 주세요.