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_csv의 index_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_values | NA로 취급할 추가 문자열 (["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

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"는 두 표에서 이름이 같은 열을 짝지으라는 뜻입니다. meta는 sample_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)가 눈에 들어옵니다.

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 → long | pivot_longer(...) | df.melt(...) |
| long → wide | pivot_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_id가001·002라면read_csv가 앞의 0을 떼고 정수1·2로 읽어 버립니다. 유전자 ID·바코드·샘플명처럼 식별자는dtype={"gene_id": "str"}로 문자열임을 명시하세요. - 문자열 열의 dtype가
object가 아니다 — pandas 3.0부터 문자열 열의 기본 자료형이object에서 전용str타입으로 바뀌었습니다.df["group"].dtype이str로 나오는 게 정상입니다. 옛 코드에서dtype == object로 문자열 열을 골랐다면 동작이 달라질 수 있으니 확인하세요. merge후 행 수가 늘어남 — 열쇠가 한쪽에서 중복이면 결합이 곱(cartesian) 형태로 불어납니다. 결합 전에meta.index.is_unique나df["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)이란? — 더 큰 행렬을 다루는 분석으로

References
- McKinney, W. (2010). Data Structures for Statistical Computing in Python. Proceedings of the 9th Python in Science Conference, 56–61. (pandas 원논문)
- The pandas development team. (2026). pandas documentation — v3.0.3. pandas.pydata.org/docs
- pandas. What's new in 3.0.0 — Copy-on-Write 기본화·문자열 dtype. whatsnew/v3.0.0
- 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
'Code Bench > Python for Bio' 카테고리의 다른 글
| numpy·scipy로 생물 데이터 수치·통계 — 배열 연산·가설검정 (0) | 2026.07.01 |
|---|---|
| scikit-learn으로 생물 데이터 머신러닝 — 분류·교차검증·ROC (0) | 2026.06.29 |
| matplotlib·seaborn으로 생물 데이터 시각화 — 발현 데이터 플롯 실전 (0) | 2026.06.25 |
| scanpy로 단일세포 RNA-seq 분석하기 — QC부터 UMAP·클러스터까지 (Python for Bio) (0) | 2026.06.23 |
| Biopython 입문 — FASTA·FASTQ 파싱부터 서열 통계까지 (Python for Bio) (0) | 2026.06.23 |