1왜 pandas나 NumPy가 아니라 polars인가
먼저 사실부터 적는다. 이 프로젝트는 처음부터 polars로 짰다. scripts/의 분석 스크립트가 모두 polars를 쓰고, 프로젝트 가상환경에는 pandas가 설치돼 있지도 않다. 노트북은 기존 스크립트의 설정(config.py)과 함수를 그대로 가져다 쓰려고 같은 라이브러리를 골랐다. pandas로도 같은 분석을 할 수 있다. 14절에 대응표를 두었다. 입문 실습(표본으로 시작하기)은 1차시와 이어지도록 pandas로 썼다.
처음에 polars를 고른 것은 이 데이터의 모양 때문이다.
| 이 데이터의 특징 | polars에서 | pandas에서 |
|---|---|---|
| parquet 9개, 1.85GB. 26개 컬럼 가운데 13개가 긴 한글 서술문이다. | scan_parquet는 읽을 계획만 세운다. 집계에 쓰는 컬럼만 읽으므로 인구통계 변수 11개를 올리면 162MB다. | read_parquet를 그냥 부르면 전체를 메모리에 올린다. columns=로 컬럼을 직접 골라 줘야 같은 효과가 난다. |
| 직무로 거른 뒤에만 서술문이 필요하다. | lf.filter(조건).select(컬럼).collect()라고 쓰면 조건에 맞는 행과 고른 컬럼만 읽는다. | 전체를 읽은 뒤 거르거나, pyarrow의 필터 인자를 따로 써야 한다. |
| 100만 행을 직업명 2,120개로 묶어 센다. | 여러 CPU 코어를 나눠 쓴다. | 기본은 코어 하나로 계산한다. |
| 값이 거의 다 문자열이다. | 문자열을 Arrow 형식으로 저장한다. | 기본 설정에서는 문자열을 파이썬 객체로 저장해 메모리를 더 쓴다. 최근 판은 Arrow 문자열을 고를 수 있다. |
NumPy는 쓰임새가 다르다
NumPy는 숫자 배열을 계산하는 라이브러리다. 행렬 곱이나 난수 생성처럼 모든 값이 같은 숫자형일 때 쓴다. 이 데이터는 컬럼마다 이름이 있고 값이 대부분 문자열인 표다. 표를 묶고 세고 거르는 일은 pandas나 polars 같은 데이터프레임 라이브러리가 맡는다. 이 프로젝트에서도 NumPy는 쓴다. scripts/10_toy_pgm.py가 확률표에서 난수로 가상 인물을 뽑을 때다.
polars를 쓸 때 주의할 점.
- 한국어 자료와 예제가 pandas보다 훨씬 적다. 검색해서 나오는 답은 대부분 pandas 코드다.
- 문법이 다르다.
df[df["a"] == 1]대신df.filter(pl.col("a") == 1)이라고 쓴다. 13절에 읽는 법을 적었다. - 판이 바뀌면서 함수 이름이 바뀐 적이 있다(
groupby→group_by) - 통계·시각화 라이브러리 가운데 pandas만 받는 것이 있다. 그럴 때는
df.to_pandas()로 바꿔서 넘긴다. pandas를 설치해야 한다.
2import 0절 준비 셀
import os # 모듈을 통째로 가져온다. os.environ처럼 앞에 이름을 붙여 쓴다
from pathlib import Path # 모듈 안의 Path 하나만 가져온다. 앞에 pathlib를 붙이지 않는다
import polars as pl # 가져오면서 짧은 별명을 붙인다. 이후로는 pl.col(...)처럼 쓴다
import matplotlib.pyplot as plt # 점(.)은 패키지 안의 하위 모듈을 가리킨다
import config as C # 직접 만든 파일 scripts/config.py도 같은 방식으로 가져온다
import config가 되려면 파이썬이 그 파일이 있는 폴더를 알아야 한다. 그래서 바로 앞줄에 sys.path.insert(0, str(ROOT / "scripts"))가 있다. 모듈을 찾는 폴더 목록의 맨 앞에 scripts를 끼워 넣는 코드다.
%matplotlib inline은 파이썬 문법이 아니다. %로 시작하는 줄은 Jupyter가 직접 처리하는 명령이고, 일반 .py 파일에 넣으면 오류가 난다.
3값과 자료형 노트북 전체
| 자료형 | 노트북에 나온 예 | 설명 |
|---|---|---|
문자열 str | NO_JOB = "무직" | 따옴표로 감싼 글자. 큰따옴표와 작은따옴표는 같다. |
정수 int, 실수 float | h = 0.38, 1024**3 | **는 거듭제곱이다. 1024의 세제곱은 1GB의 바이트 수다. |
참거짓 bool | include_former=False | True와 False. 첫 글자가 대문자다. |
없음 None | ("sex", None), base=None | 값이 없다는 표시. if base is None으로 확인한다. |
리스트 list | EDU_ORDER = ["무학", "초등학교", ...] | 순서가 있는 묶음. 대괄호를 쓰고, 나중에 고칠 수 있다. |
튜플 tuple | ("sex", None), exclude=() | 순서가 있는 묶음. 소괄호를 쓰고, 만든 뒤에는 고칠 수 없다. ()는 빈 튜플이다. |
사전 dict | {"직무": label, "인원": n} | 이름(키)과 값의 짝. r["occupation"]처럼 키로 꺼낸다. |
range | range(15, 105, 5) | 15부터 105 앞까지 5씩 늘어나는 수열. 끝 값은 들어가지 않는다. |
이름을 대문자로 쓴 변수(DEMO_COLS, JOB_KEYWORDS)는 설정값이라는 뜻의 관례다. 문법으로 막는 것은 아니어서 값을 바꿀 수는 있다.
4f-string print가 나오는 거의 모든 셀
문자열 앞에 f를 붙이면 중괄호 안의 식을 계산해서 글자로 넣는다. 콜론 뒤에는 표시 형식을 적는다.
print(f"{n_rows:,}행 x {len(schema)}컬럼") # 1,000,000행 x 26컬럼 (:, 는 천 단위 쉼표)
print(f"parquet 파일 {len(files)}개, {size_gb:.2f} GB") # 1.85 GB (:.2f 는 소수 둘째 자리까지)
print(f"메모리에 올린 크기: {demo.estimated_size('mb'):.0f} MB") # 162 MB (:.0f 는 소수점 없이)
print(f"[{col}]") # [sex] (형식 없이 값만 넣기)
세 번째 줄처럼 중괄호 안에서 따옴표가 필요하면 바깥과 다른 종류를 쓴다. 바깥이 큰따옴표여서 안에는 'mb'라고 썼다.
5인덱싱과 슬라이싱 3-3, 4-3, 4-7
EDU_ORDER = ["무학", "초등학교", "중학교", "고등학교", "2~3년제 전문대학", "4년제 대학교", "대학원"]
# 번호 0 1 2 3 4 5 6
EDU_ORDER[0] # "무학" 번호는 0부터 센다
EDU_ORDER[-1] # "대학원" 음수는 뒤에서부터 센다
EDU_ORDER[4:] # ["2~3년제 전문대학", "4년제 대학교", "대학원"] 4번부터 끝까지
노트북의 "전문대 이상(%)"이 EDU_ORDER[4:]를 쓴다. 4-7에서 가장 높은 학력과 낮은 학력을 고를 때는 edu_in_job[-1]과 edu_in_job[0]을 쓴다.
대괄호는 사전에서 값을 꺼낼 때(r["age"])와 데이터프레임에서 컬럼을 꺼낼 때(demo["sex"])도 쓴다. 모양은 같지만 안에 번호가 아니라 이름이 들어간다.
6조건문 1절, 4-2, 4-3
if not files: # 빈 리스트는 거짓으로 취급된다. "파일이 하나도 없으면"이라는 뜻이다
snapshot_download(...)
if job.height == 0: # ==는 같은지 비교, =는 값을 넣기
raise ValueError("...") # 오류를 일부러 일으켜 여기서 멈춘다
if df.height: # 0이 아닌 수는 참이다. "한 명이라도 있으면"
rows.append(job_summary(df, label))
else: # 위 조건이 거짓일 때
print(f"'{label}'에 걸리는 직업이 없다.")
들여쓰기가 문법이다. if 줄 끝에 콜론을 찍고, 그 조건에 딸린 줄은 네 칸 들여 쓴다. 들여쓰기가 끝나는 곳에서 조건문도 끝난다.
한 줄 조건식
base = employed if base is None else base
# 참일 때 값 조건 거짓일 때 값
return out.head(top) if top else out # top이 None이나 0이면 거짓이므로 전체를 돌려준다
거짓으로 취급되는 값
False, None, 0, 빈 문자열 "", 빈 리스트 [], 빈 튜플 ()은 조건 자리에서 거짓이다. 그래서 if not files:, if top, if k.strip() 같은 짧은 표현이 가능하다.
7반복문 3-2, 3-3, 4-4, 4-8
for col in ["sex", "marital_status", "education_level"]: # 리스트의 항목을 하나씩 col에 넣으며 반복한다
print(share(demo, col))
for col, top in [("sex", None), ("bachelors_field", 8)]: # 항목이 튜플이면 변수 두 개로 나눠 받는다 (언패킹)
print(compare_share(job, col, top=top))
for sex, color in zip(["남자", "여자"], [C.COLOR_1, C.COLOR_2]): # zip은 두 리스트를 같은 자리끼리 짝짓는다
ax.hist(..., color=color, label=sex)
for label, spec in JOB_SETS.items(): # 사전은 .items()로 (키, 값) 짝을 하나씩 꺼낸다
...
for i, k in enumerate(EDU_ORDER): # enumerate는 (번호, 항목) 짝을 만든다. (0, "무학"), (1, "초등학교"), ...
... # 노트북에서는 이 모양이 8절의 사전 컴프리헨션 안에 들어 있다
while
ROOT = Path.cwd().resolve()
while not (ROOT / "scripts" / "config.py").exists() and ROOT != ROOT.parent:
ROOT = ROOT.parent # 한 폴더 위로 올라간다
while은 조건이 참인 동안 되풀이한다. 위 코드는 scripts/config.py가 보일 때까지 부모 폴더로 올라간다. 드라이브 맨 위에서는 부모가 자기 자신이므로 ROOT != ROOT.parent가 거짓이 되어 멈춘다. 이 조건이 없으면 파일을 못 찾았을 때 끝나지 않는다.
8컴프리헨션 3-1, 3-3, 4-1, 4-2, 4-7
반복문으로 리스트를 만드는 일을 한 줄로 줄여 쓴 문법이다. 노트북에서 가장 자주 나온다.
# 컴프리헨션
[demo[c].null_count() for c in DEMO_COLS]
# 같은 일을 하는 반복문
result = []
for c in DEMO_COLS:
result.append(demo[c].null_count())
# 뒤에 if를 붙이면 조건에 맞는 것만 남긴다
[e for e in EDU_ORDER if e in job["education_level"].unique().to_list()]
# 환경변수 "간호, 개발, "을 쉼표로 쪼개고, 양끝 공백을 떼고, 빈 것은 버린다
[k.strip() for k in os.environ.get("NPK_JOB_KEYWORDS", "간호").split(",") if k.strip()]
# 중괄호와 콜론을 쓰면 사전이 된다. {"무학": 0, "초등학교": 1, ...}
{k: i for i, k in enumerate(EDU_ORDER)}
# 대괄호 없이 함수 안에 바로 쓰면 리스트를 만들지 않고 하나씩 넘긴다
sum(f.stat().st_size for f in files)
읽는 순서는 for부터다. "DEMO_COLS의 각 c에 대해, demo[c].null_count()를 구해서 모은다."
9함수 3-3의 share, 4절의 모든 함수
def job_view(keywords, exclude=(), include_former=False, with_text=True):
"""직무 집단의 행을 돌려준다. with_text=True면 서술문 컬럼도 함께 읽는다."""
if isinstance(keywords, str):
keywords = [keywords]
...
return demo.filter(cond)
| 부분 | 뜻 |
|---|---|
def 이름(인자): | 함수를 만든다. 이 줄에서는 실행되지 않고, 나중에 job_view(...)라고 부를 때 실행된다. |
exclude=() | 기본값. 부를 때 이 인자를 주지 않으면 빈 튜플이 들어간다. 기본값이 없는 keywords는 꼭 줘야 한다. |
"""...""" | 함수 설명(docstring). 실행에는 영향이 없다. help(job_view)나 편집기의 풍선 도움말에 나온다. |
isinstance(keywords, str) | 값이 문자열인지 확인한다. find_jobs("간호")와 find_jobs(["간호", "보건"])를 둘 다 받으려고 넣었다. |
return | 결과를 돌려주고 함수를 끝낸다. job_summary는 사전을 돌려주고, 노트북은 그 사전들을 모아 표로 만든다. |
부르는 방법 두 가지
job_view(JOB_KEYWORDS, JOB_EXCLUDE, INCLUDE_FORMER) # 자리로 주기: 적은 순서대로 들어간다
job_view(spec["keywords"], include_former=False, with_text=False) # 이름으로 주기: 순서와 상관없고 읽기 쉽다
함수 밖의 변수를 읽는다
share나 job_summary는 인자로 받지 않은 demo, employed, EDU_ORDER를 그대로 쓴다. 함수 안에 없는 이름은 바깥(노트북 전체)에서 찾기 때문이다. 셀을 위에서부터 차례로 실행해야 하는 것도 이 때문이다. employed를 만드는 3-4 셀을 건너뛰고 4절을 실행하면 이름을 찾지 못해 오류가 난다.
10환경변수와 경로 0절, 1절, 4-1
os.environ
os.environ.setdefault("HF_HUB_DISABLE_XET", "1") # 값이 없을 때만 "1"을 넣는다. 이미 있으면 그대로 둔다
os.environ.get("NPK_JOB_KEYWORDS", "간호") # 값을 읽는다. 없으면 두 번째 인자 "간호"를 돌려준다
int(os.environ.get("NPK_JOB_N_EXAMPLES", "3")) # 환경변수는 늘 문자열이다. 숫자로 쓰려면 int()로 바꾼다
os.environ.get("NPK_JOB_INCLUDE_FORMER", "0") == "1" # 비교 결과가 곧 True/False다
설정값을 코드에 직접 적지 않고 환경변수에서 읽으면, 코드를 고치지 않고 값만 바꿔 다시 돌릴 수 있다. 이 프로젝트는 .env 파일에 값을 적어 두고 config.py가 읽는다.
pathlib
ROOT / "scripts" / "config.py" # 나누기 기호로 경로를 잇는다. 윈도의 \와 /를 신경 쓰지 않아도 된다
(ROOT / "scripts" / "config.py").exists() # 파일이 있는가
ROOT.parent # 한 단계 위 폴더
C.DATA_DIR.rglob("*.parquet") # 하위 폴더까지 뒤져 이름이 맞는 파일을 모두 찾는다
f.stat().st_size # 파일 크기 (바이트)
11메서드 체이닝 3-3, 4-4, 4-5, 4-6
메서드가 돌려준 결과에 다시 점을 찍어 다음 메서드를 부르는 방식이다. 길어지면 전체를 소괄호로 감싼다. 소괄호 안에서는 줄을 마음대로 바꿀 수 있다.
def share(df, col):
return (
df.group_by(col).len() # ① 값별로 묶어 센다
.with_columns(...) # ② ①의 결과에 비율 컬럼을 더한다
.rename({"len": "인원"}) # ③ ②의 결과에서 컬럼 이름을 바꾼다
.sort("인원", descending=True) # ④ ③의 결과를 정렬한다
)
위에서 아래로 읽는다. 각 줄은 바로 윗줄의 결과를 받아 새 표를 돌려준다. 원래 표 df는 바뀌지 않는다. 중간 결과를 보고 싶으면 아래 줄을 #로 막고 실행하면 된다.
12그 밖에 나온 것 노트북 전체
| 코드 | 뜻 |
|---|---|
len(files), sum(...), min(n, df.height), round(x, 1), sorted(...) | 내장 함수. 개수, 합, 작은 쪽, 반올림, 정렬한 새 리스트. |
"+".join(JOB_KEYWORDS) | 리스트의 문자열들을 +로 이어 하나로 만든다. ["간호", "보건"] → "간호+보건". |
"a, b".split(","), " a ".strip() | 문자열을 쪼개 리스트로 만들기, 양끝 공백 떼기. |
"=" * 100 | 문자열에 정수를 곱하면 그만큼 되풀이된다. 구분선을 그릴 때 썼다. |
e in 리스트 | 들어 있으면 True. for e in ...의 in과 글자는 같지만 역할이 다르다. |
r"'([^']+)'" | 앞의 r은 역슬래시를 특수문자로 풀지 않는 문자열이라는 표시다. 정규식을 적을 때 붙인다. 이 정규식은 "작은따옴표, 작은따옴표가 아닌 글자 한 개 이상, 작은따옴표"를 찾는다. |
spec.get("exclude", ()) | 사전에서 값을 꺼내되, 키가 없으면 오류를 내지 않고 두 번째 인자를 돌려준다. spec["exclude"]는 키가 없으면 오류가 난다. |
fig, axes = plt.subplots(1, 2) | 함수가 값 두 개를 튜플로 돌려주고, 왼쪽에서 변수 두 개로 나눠 받는다. 7절의 언패킹과 같다. |
셀 마지막 줄의 demo.head(5) | Jupyter는 셀의 마지막 식을 자동으로 보여 준다. 중간 줄의 결과는 print()로 찍어야 보인다. |
13polars 코드를 읽는 법 2절 이후 전부
계획과 실행이 나뉘어 있다
lf = pl.scan_parquet(C.parquet_glob()) # LazyFrame. 아직 아무것도 읽지 않았다
demo = lf.select(["uuid"] + DEMO_COLS).collect() # collect()에서 실제로 읽는다. 결과는 DataFrame이다
lf로 시작하는 줄은 .collect()가 나올 때까지 계획만 쌓는다. polars는 계획 전체를 보고 필요한 컬럼과 행만 읽는다. demo와 job은 이미 메모리에 있는 DataFrame이라 .collect()가 필요 없다.
pl.col(...)은 값이 아니라 식이다
pl.col("age").median() # "age 컬럼의 중앙값"이라는 식. 혼자서는 계산되지 않는다
demo.select(pl.col("age").median()) # select, filter, with_columns, agg 안에 넣어야 계산된다
노트북의 job_expr()가 돌려주는 것도 이런 식이다. 조건식을 함수로 만들어 두고 demo.filter(...)와 lf.filter(...) 양쪽에 넣어 쓴다.
식 안에서는 and, or, not 대신 기호를 쓴다
hit = hit & ~occ.str.contains(k, literal=True)
# & 그리고 ~ 아니다 | 또는
파이썬의 and, or, not은 값 하나를 참거짓으로 따진다. 컬럼은 100만 행짜리라서 행마다 따로 계산해야 하므로 &, |, ~를 쓴다. pandas와 NumPy도 같다. 비교와 섞을 때는 (pl.col("a") == 1) & (pl.col("b") == 2)처럼 괄호를 쳐야 한다.
자주 나온 메서드
| 메서드 | 하는 일 |
|---|---|
select(...) | 컬럼을 고르거나 계산해서 새 표를 만든다. |
filter(조건식) | 조건이 참인 행만 남긴다. |
with_columns(식.alias("이름")) | 기존 표에 컬럼을 더한다. alias가 새 컬럼의 이름이다. |
group_by(컬럼).len() / .agg(...) | 값별로 묶어 행 수를 세거나, 여러 값을 한꺼번에 계산한다. |
pl.when(조건).then(값).otherwise(값) | 행마다 조건을 따져 값을 정한다. if-elif-else에 해당한다. pl.lit("무직")은 컬럼 이름이 아니라 글자 그대로의 값이라는 표시다. |
.over("occupation") | 합계나 평균을 전체가 아니라 그 묶음 안에서만 구한다. 행 수는 줄지 않는다. |
join(다른표, on=컬럼, how="full") | 두 표를 같은 값끼리 옆으로 붙인다. full은 한쪽에만 있는 값도 남긴다. |
pivot(on, index, values) | 긴 표를 교차표로 펼친다. |
explode(컬럼) | 리스트가 든 칸을 항목마다 한 행으로 펼친다. |
.str.contains(낱말, literal=True) | 문자열에 낱말이 들어 있는지. literal=True가 없으면 낱말을 정규식으로 읽는다. 그러면 "."로 찾았을 때 직업명 2,120개가 전부 걸리고, "("로 찾으면 정규식 오류가 난다. |
demo["sex"], .height, .to_dicts() | 컬럼 하나 꺼내기, 행 수, 행마다 사전으로 바꾸기. |
14같은 일을 pandas로 쓰면
검색해서 나온 pandas 코드를 polars로 옮기거나, 반대로 옮길 때 쓰는 대응표다. pandas 쪽은 실행해 확인하지 않았다. 이 환경에 pandas가 없어서다.
| 하려는 일 | polars (노트북) | pandas |
|---|---|---|
| 일부 컬럼만 읽기 | pl.scan_parquet(경로).select(cols).collect() | pd.read_parquet(폴더, columns=cols) |
| 행 거르기 | df.filter(pl.col("a") == 1) | df[df["a"] == 1] |
| 컬럼 더하기 | df.with_columns((식).alias("n")) | df.assign(n=식) 또는 df["n"] = 식 |
| 값별 개수 | df.group_by("c").len() | df["c"].value_counts() |
| 묶어서 여러 값 계산 | df.group_by("c").agg(pl.col("age").median()) | df.groupby("c").agg(age=("age", "median")) |
| 낱말 포함 여부 | .str.contains(k, literal=True) | .str.contains(k, regex=False) |
| 조건에 따라 값 정하기 | pl.when(c1).then(a).when(c2).then(b).otherwise(d) | np.select([c1, c2], [a, b], default=d) |
| 묶음 안에서의 합 | pl.col("len").sum().over("g") | df.groupby("g")["len"].transform("sum") |
| 교차표 (행 기준 %) | group_by → over → pivot | pd.crosstab(df["a"], df["b"], normalize="index") |
| 두 표 붙이기 | a.join(b, on="k", how="full") | a.merge(b, on="k", how="outer") |
| 리스트 펼치기 | df.explode("s") | df.explode("s") |
| 무작위로 뽑기 | df.sample(n=3, seed=42) | df.sample(n=3, random_state=42) |
| 행 수, 고유값 수 | df.height, s.n_unique() | len(df), s.nunique() |
| 서로 바꾸기 | df.to_pandas() | pl.from_pandas(df) |
15읽고 나서 확인해 볼 것
노트북을 열어 놓고 직접 해 보면 위 내용이 맞는지 바로 확인된다.