포스트

NumPy, Pandas 기초 (2) — 배열 만들기, 고르기, 접기: 인덱싱·axis·브로드캐스팅

NumPy 배열의 생성과 reshape, 뷰와 복사의 차이, 집계의 방향을 정하는 axis, 그리고 크기가 다른 배열끼리 연산되는 브로드캐스팅 규칙을 정리했습니다.

NumPy, Pandas 기초 (2) — 배열 만들기, 고르기, 접기: 인덱싱·axis·브로드캐스팅

NumPy와 pandas를 기초부터 정리하는 6편 시리즈의 두 번째 글입니다. 1편에서 다룬 벡터화와 dtype 개념을 전제로 합니다.

배열 만들기 — 상황별 생성 함수

배열을 만드는 방법은 데이터가 어디서 오느냐에 따라 갈립니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import numpy as np

# 이미 있는 데이터에서
np.array([1, 2, 3])                  # 리스트 → 배열
np.array([[1, 2, 3], [4, 5, 6]])     # 중첩 리스트 → 2차원 배열

# 규칙적인 수열
np.arange(0, 10, 2)                  # [0 2 4 6 8]     — 시작, 끝(미포함), 간격
np.linspace(0, 1, 5)                 # [0. 0.25 0.5 0.75 1.] — 시작, 끝(포함), 개수

# 틀 먼저 만들기
np.zeros((3, 4))                     # 0으로 채운 3×4
np.ones(5)                           # 1로 채운 길이 5
np.full((2, 2), -1)                  # 지정값으로 채우기

# 난수
rng = np.random.default_rng(42)      # 시드 고정 — 재현 가능성의 기본
rng.normal(0, 1, size=(3, 3))        # 표준정규분포 3×3
rng.integers(0, 10, size=5)          # 0~9 정수 5개

두 가지만 짚습니다.

arange는 끝을 포함하지 않고, linspace는 포함합니다. “간격이 중요하면 arange, 개수가 중요하면 linspace”로 기억하면 됩니다. 그래프 그릴 x축은 linspace, 인덱스 수열은 arange가 자연스럽습니다.

난수는 default_rng(시드)가 현재 표준입니다. 예전 방식(np.random.seed)은 전역 상태를 건드려서 다른 코드와 간섭합니다. 시드를 고정하는 이유는 실험 재현 — 같은 시드면 같은 난수가 나오므로, 모델 학습 결과를 다시 만들 수 있습니다.

shape와 reshape — 같은 데이터, 다른 모양

배열의 모양은 shape 속성에 튜플로 담겨 있고, reshape로 바꿉니다. 중요한 것은 데이터는 그대로 두고 해석만 바꾼다는 점입니다. 메모리 복사가 일어나지 않습니다.

1
2
3
4
a = np.arange(12)        # shape (12,)
a.reshape(3, 4)          # shape (3, 4)
a.reshape(3, -1)         # -1: "나머지는 알아서 계산" → (3, 4)
a.reshape(2, 3, 2)       # 3차원도 가능

-1은 실무에서 매우 자주 씁니다. scikit-learn이 “2차원 입력을 달라”고 요구할 때 X.reshape(-1, 1)(행 개수는 알아서, 열은 1개)이 관용구처럼 쓰입니다.

차원이 헷갈릴 때는 shape 튜플을 바깥에서 안쪽으로 읽으면 됩니다. (2, 3, 4)는 “3×4짜리 행렬이 2장” — 이미지 데이터라면 (장수, 높이, 너비)인 식입니다.

인덱싱과 슬라이싱 — 슬라이스는 복사가 아니라 뷰다

기본 문법은 리스트와 같습니다. 2차원부터는 쉼표로 축을 구분합니다.

1
2
3
4
5
6
m = np.arange(12).reshape(3, 4)

m[0, 2]       # 0행 2열의 값
m[1]          # 1행 전체
m[:, 2]       # 모든 행의 2열 — 열 하나 뽑기
m[0:2, 1:3]   # 부분 행렬

문법은 같지만 동작이 결정적으로 다른 지점이 있습니다. NumPy 슬라이스는 원본 메모리를 공유하는 뷰(view)입니다.

1
2
3
4
a = np.arange(10)
b = a[2:5]        # 뷰 — 새 배열이 아니다
b[0] = 99
a                 # [0 1 99 3 4 ...] — 원본이 바뀌었다

Python 리스트는 lst[2:5]가 새 리스트를 만들지만, NumPy는 대용량 데이터의 복사 비용을 아끼기 위해 같은 메모리를 가리키는 창(view)만 냅니다. 원본과 분리하고 싶으면 명시적으로 복사해야 합니다.

1
b = a[2:5].copy()   # 이제 b를 수정해도 a는 무관

조건으로 고르기 — boolean 인덱싱

가장 많이 쓰게 될 패턴입니다. 비교 연산이 배열 전체에 벡터화로 적용되어 True/False 배열(마스크)이 나오고, 그 마스크로 원소를 고릅니다.

1
2
3
4
5
a = np.array([3, -1, 7, 0, -5, 2])

mask = a > 0            # [ True False  True False False  True]
a[mask]                 # [3 7 2]
a[(a > 0) & (a < 5)]    # [3 2] — 조건 결합은 & | ~, 각 조건에 괄호 필수

조건 결합에 and/or를 쓰면 에러가 납니다. Python의 and는 배열 전체를 하나의 참/거짓으로 판정하려 들기 때문입니다. &(그리고), |(또는), ~(부정), 그리고 각 조건마다 괄호 — 이 규칙은 pandas에서도 동일하게 적용되므로 여기서 손에 익혀야 합니다.

정수 배열로 원하는 위치를 골라내는 fancy 인덱싱도 있습니다.

1
a[[0, 2, 5]]            # [3 7 2] — 0, 2, 5번 원소

여기서 뷰/복사 규칙이 다시 나옵니다. 슬라이스는 뷰지만, boolean·fancy 인덱싱은 항상 복사본을 만듭니다. 원소를 “골라 모으는” 작업은 연속 메모리로 표현할 수 없기 때문입니다. 이 구분은 4편에서 pandas의 chained assignment 문제를 이해하는 밑거름이 됩니다.

값을 고르는 대신 조건에 따라 바꿔치기하려면 np.where를 씁니다.

1
np.where(a > 0, a, 0)   # [3 0 7 0 0 2] — 양수는 그대로, 음수는 0으로

엑셀의 IF 함수를 배열 전체에 한 번에 적용하는 것과 같습니다.

정렬과 위치 찾기 — sort, argsort, argmax

“값을 정렬하는 것”과 “몇 번째가 최대인지 찾는 것”은 다른 질문입니다. NumPy에는 값 대신 위치(인덱스)를 돌려주는 arg 계열 함수가 따로 있습니다.

1
2
3
4
5
scores = np.array([92, 68, 85, 77])   # 학생별 점수

np.sort(scores)      # [68 77 85 92] — 정렬된 "값"
scores.argmax()      # 0             — 최댓값의 "위치"
np.argsort(scores)   # [1 3 2 0]     — 정렬 순서대로의 "위치들"

위치가 왜 필요한가 — 그 배열이 다른 배열과 나란히 연결되어 있기 때문입니다. 점수 배열과 학생 이름 배열이 짝을 이룰 때, “점수 상위 3명”은 이렇게 나옵니다.

1
2
3
4
students = np.array(["김철수", "이영희", "박민수", "정수진"])

top3 = np.argsort(scores)[::-1][:3]   # 내림차순 상위 3개의 위치
students[top3]                        # ['김철수' '박민수' '정수진']

argsort가 위치를 주고, fancy 인덱싱이 그 위치로 다른 배열에서 값을 꺼냅니다 — 앞에서 배운 도구들이 이렇게 조합됩니다. [::-1]은 배열을 뒤집는 슬라이스 관용구입니다.

참고로 np.sort(a)는 정렬된 복사본을 반환하고, a.sort()는 원본을 제자리에서 바꿉니다. 함수형(복사)과 메서드형(제자리)의 동작이 다른 흔치 않은 지점이라 한 번은 짚어둘 만합니다.

NaN — 배열에 뚫린 구멍

실무 배열에는 결측값 np.nan이 섞여 들어옵니다. 자세한 처리는 6편에서 다루고, 여기서는 성질 두 가지만 미리 알아둡니다.

NaN은 전염됩니다. 연산에 NaN이 하나라도 끼면 결과가 NaN입니다.

1
2
3
4
5
6
a = np.array([1.0, np.nan, 3.0])

a.sum()          # nan — 전체가 오염
np.nansum(a)     # 4.0 — NaN을 무시하는 버전
np.nanmean(a)    # 2.0
np.isnan(a)      # [False  True False] — 위치 확인

집계 결과가 갑자기 nan으로 나온다면 데이터 어딘가에 NaN이 숨어 있다는 신호이고, 무시하고 집계하려면 nansum/nanmean 계열을 씁니다.

NaN은 float입니다. 부동소수점 표준에 정의된 특수값이라 정수 배열에는 들어갈 수 없습니다. 이 제약이 pandas에서 일으키는 연쇄 반응(정수 컬럼의 float 승격)은 6편의 주요 소재입니다.

axis — 집계의 방향, 가장 많이 헷갈리는 개념

sum, mean, max, argmax 같은 집계 함수에는 전부 axis 인자가 있는데, 처음엔 반드시 헷갈립니다. “axis=0이 행 방향이니까 행별 합인가?” — 아닙니다. 반대입니다.

1
2
3
4
5
6
scores = np.array([[85, 90, 77],
                   [70, 95, 88]])   # 학생 2명 × 과목 3개, shape (2, 3)

scores.sum()          # 505           — 전체 합
scores.mean(axis=0)   # [77.5 92.5 82.5] — 과목별 평균 (학생 차원을 접음)
scores.mean(axis=1)   # [84.  84.33]     — 학생별 평균 (과목 차원을 접음)

외우는 방법은 하나입니다. axis는 “없어지는 차원”이다.

(2, 3) 배열에 axis=0을 주면 0번 차원(학생, 크기 2)이 사라지고 shape (3,)이 남습니다. 남은 것이 과목 3개이므로 결과는 과목별 값입니다. “무엇을 기준으로 묶는가”가 아니라 “어느 차원을 접어서 없애는가”로 읽으면 3차원 이상에서도 틀리지 않습니다.

1
2
3
t = np.zeros((10, 28, 28))    # 이미지 10장
t.mean(axis=0).shape           # (28, 28) — 10장을 접어서 평균 이미지
t.mean(axis=(1, 2)).shape      # (10,)    — 각 이미지의 평균 밝기

pandas의 df.sum(axis=0)(열별 합), df.dropna(axis=1)(컬럼 삭제)도 같은 규칙을 따르므로, 여기서 한 번 제대로 잡아두면 계속 써먹습니다.

브로드캐스팅 — 모양이 달라도 연산되는 규칙

크기가 다른 배열끼리 연산하면 NumPy가 작은 쪽을 “늘려서” 맞춥니다. 실제로 메모리를 복사하는 게 아니라 계산할 때만 그렇게 취급하므로 비용도 없습니다.

가장 단순한 형태는 스칼라입니다.

1
scores + 5       # 전원 5점 가산 — 스칼라가 (2,3)으로 늘어난 셈

진짜 유용한 것은 벡터와 행렬의 연산입니다.

1
2
3
4
5
scores = np.array([[85, 90, 77],
                   [70, 95, 88]])       # 학생 2명 × 과목 3개, (2, 3)

subject_mean = scores.mean(axis=0)      # (3,)  — 과목별 평균 [77.5 92.5 82.5]
scores - subject_mean                   # (2,3) — 과목 평균을 뺀 편차 점수

과목마다 난이도가 달라 평균이 다를 때, 과목별 평균을 빼면 “그 과목 안에서 얼마나 잘했는가”만 남습니다. 이 두 줄이 바로 표준화(standardization)입니다. 피처마다 평균을 빼고 표준편차로 나누는 전처리를 반복문 없이 쓸 수 있습니다.

1
X_scaled = (X - X.mean(axis=0)) / X.std(axis=0)

규칙은 하나

두 배열의 shape를 뒤쪽 차원부터 맞대어 비교합니다. 각 자리에서 (1) 크기가 같거나 (2) 한쪽이 1이면 통과, 아니면 에러입니다. 차원 수가 다르면 짧은 쪽 앞에 1을 채워서 비교합니다.

1
2
3
(2, 3)  vs  (3,)   →  (2, 3) vs (1, 3)  →  통과: 결과 (2, 3)
(2, 3)  vs  (2,)   →  (2, 3) vs (1, 2)  →  에러: 3 vs 2
(4, 1)  vs  (3,)   →  (4, 1) vs (1, 3)  →  통과: 결과 (4, 3)

두 번째 사례가 흔한 실수입니다. “행 개수가 같으니 되겠지”는 통하지 않습니다 — 비교는 뒤에서부터입니다. 행 방향으로 빼고 싶으면 (2,)(2, 1)로 바꿔야 합니다.

1
2
3
4
student_mean = scores.mean(axis=1)               # (2,) — 학생별 평균
scores - student_mean                            # 에러
scores - student_mean.reshape(-1, 1)             # (2,3) - (2,1) → 통과
scores - scores.mean(axis=1, keepdims=True)      # 같은 결과 — keepdims가 (2,1)을 유지

세 번째 사례 (4,1) vs (3,)처럼 양쪽이 서로를 늘리는 조합은 모든 쌍의 조합을 만들 때 씁니다. 학생 4명 × 배점 기준 3안의 환산 점수표 같은 것을 반복문 없이 만들 수 있습니다.

유니버설 함수 — 수학 함수도 배열 단위로

np.exp, np.log, np.sqrt, np.abs 같은 함수들은 전부 원소별로 벡터화되어 동작합니다(universal function, ufunc). math.log를 반복문으로 돌리는 대신 np.log(arr) 한 줄이면 됩니다.

1
np.log1p(arr)    # log(1+x) — 0이 섞인 데이터에 로그를 씌울 때의 관용구

결석 횟수나 제출 건수처럼 0이 존재하는 데이터에 np.log를 그대로 쓰면 -inf가 나옵니다. log1p(그리고 역변환 expm1)는 이 문제를 피하는 표준 도구로, 한쪽으로 쏠린 분포를 펴는 변환에 자주 등장합니다.

정리

개념한 줄 요약
생성 함수간격이 중요하면 arange, 개수가 중요하면 linspace, 난수는 default_rng(시드)
reshape데이터는 그대로, 해석만 변경. -1은 “알아서 계산”
슬라이스뷰 — 원본 메모리 공유. 분리하려면 .copy()
boolean/fancy항상 복사. 조건 결합은 & \| ~ + 괄호
arg 계열값이 아니라 위치를 반환. argsort + fancy 인덱싱으로 상위 N
NaN전염된다. 무시 집계는 nansum/nanmean, 확인은 isnan
axis“없어지는 차원”. axis=0은 행을 접어 열별 결과
브로드캐스팅뒤 차원부터 비교, 같거나 1이면 통과. 행 방향은 keepdimsreshape(-1,1)

배열을 다루는 손은 이제 준비됐습니다. 다음 편부터 pandas로 넘어가서, 이 배열에 라벨이 붙으면 무엇이 달라지는지 — 그리고 실무 데이터를 받자마자 해야 할 점검부터 정리합니다.

다음 글: NumPy, Pandas 기초 (3) — Series와 DataFrame, 데이터 첫 점검

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.