포스트

NumPy 기초 (2) - Basic Grammar

배열 생성부터 속성, 형태 변경, Indexing, 조건 선택, Broadcasting, axis 집계, 결합, 정렬, NaN 처리까지 NumPy 기본 문법을 정리했습니다.

NumPy 기초 (2) - Basic Grammar

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

내용
ndarray 생성np.array, arange/linspace, zeros/ones/full, random
ndarray의 속성ndim/shape/size, dtype/itemsize/nbytes
ndarray의 shape 변경reshape-1, flatten/ravel, T
indexing과 slicing기본 indexing, fancy indexing, slicing, view와 copy
Conditional selectionboolean 마스크, & \| ~ 결합, np.where
ndarray 연산elementwise 연산, Broadcasting, *@
Aggregation과 axissum/mean/min/max, argmax, axis
ndarray mergeconcatenate, stack, vstack/hstack
Sortsort, argsort
NaN 처리isnan, nansum/nanmean, NaN 비교

ndarray 생성

ndarray 생성 함수는 쓰임에 따라 네 가지로 나뉩니다. 1. 기존 데이터를 ndarray로 바꿀 때, 2. 수열을 만들 때, 3. 초기화된 ndarray를 만들 때, 4. 난수(random number)를 만들 때입니다.

np.array

이미 있는 데이터(list, tuple)를 ndarray로 바꿉니다.

1
2
3
4
5
import numpy as np

np.array([1, 2, 3])                     # list를 ndarray로
np.array([[1, 2, 3], [4, 5, 6]])        # 중첩 list는 2차원 ndarray로
np.array([1, 2, 3], dtype=np.float64)   # element의 dtype을 지정해서 생성

np.arange, np.linspace

규칙적인 수열을 ndarray로 만듭니다.

1
2
np.arange(0, 10, 2)     # [0 2 4 6 8], 시작/끝(미포함)/간격
np.linspace(0, 10, 2)   # [ 0. 10.], 시작/끝(포함)/개수

np.arange는 간격을 기준으로 세며 구간의 끝을 포함하지 않고, np.linspace는 개수를 기준으로 세며 구간의 끝을 포함합니다. 0, 1, …, n-1처럼 끝을 뺀 index 수열에는 np.arange가 맞고, 닫힌 구간 [1, 3]처럼 끝점을 포함해 점을 찍을 때는 np.linspace가 맞습니다.

np.zeros, np.ones, np.full

초기화된 ndarray를 만들 때 사용합니다. 채우는 값이 zeros는 0, ones는 1로 정해져 있고, 그 외의 값은 full에 지정합니다.

1
2
3
np.zeros((3, 4))       # 0으로 채운 3×4
np.ones(5)             # 1로 채운 길이 5
np.full((2, 2), -1)    # [[-1 -1] [-1 -1]], 지정값 -1로 채움

np.random

난수 ndarray를 만들 때 사용합니다.

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

난수 생성은 default_rng(시드) 방식이 표준입니다(2026년 기준). 시드를 고정하면 같은 난수가 다시 나오므로, 실험 결과를 재현할 수 있습니다.

ndarray의 속성

ndarray의 shape, dtype 같은 속성을 확인할 때 사용합니다.

ndim, shape, size

1
2
3
4
5
a = np.arange(12).reshape(3, 4)   # arange 인자가 하나면 0부터 12 미포함까지

a.ndim    # 2, 차원 수
a.shape   # (3, 4), 각 차원의 크기
a.size    # 12, 전체 element 수

shape 튜플은 바깥에서 안쪽으로 읽습니다. (2, 3, 4)는 3×4 행렬이 2장이라는 뜻입니다. 이미지 데이터라면 (장수, 높이, 너비)인 식입니다.

dtype, itemsize, nbytes

1
2
3
a.dtype      # int64, element의 dtype
a.itemsize   # 8, element 하나의 byte 수
a.nbytes     # 96, ndarray 전체의 byte 수 (= size × itemsize)

ndarray의 shape 변경

reshape

reshape는 같은 데이터를 다른 shape로 읽는 새 ndarray를 반환합니다. 호출해도 원본은 바뀌지 않으므로, 반환값을 변수에 받아서 씁니다.

1
2
3
4
5
a = np.arange(12)        # [0 1 2 ... 11], shape (12,)
b = a.reshape(3, 4)      # 반환값을 받는다

a.shape   # (12,), 원본은 그대로
b.shape   # (3, 4)

이때 b가 데이터를 복사해 간 것은 아닙니다. element 12개짜리 메모리는 하나이고, a는 그것을 (12,)로, b(3, 4)로 읽습니다. 복사가 없어서 큰 배열도 비용 없이 모양을 바꿀 수 있습니다. 대신 메모리를 공유하므로 b에 값을 쓰면 원본에도 반영됩니다.

1
2
b[0, 0] = 99
a[0]      # 99, 메모리를 공유한다

원본까지 바뀌는 것은 이렇게 b의 메모리에 직접 쓰는 경우뿐입니다. b + 10, b * 2처럼 결과를 새 배열로 반환하는 일반 연산은 공유 메모리를 건드리지 않습니다.

shape 자리에 -1을 하나 넣으면 그 차원은 전체 element 수에 맞춰 자동 계산됩니다.

1
2
a.reshape(3, -1)     # (3, 4), 12개를 3행으로 나누면 열은 4
a.reshape(2, 3, 2)   # 3차원도 가능, element 수만 맞으면 된다

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

flatten과 ravel

flattenravel은 모두 ndarray를 1차원으로 폅니다. 차이는 반환 방식입니다. flatten은 항상 copy를, ravel은 가능하면 view를 반환합니다.

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

m.flatten()   # [0 1 2 3 4 5], 항상 copy
m.ravel()     # [0 1 2 3 4 5], 가능하면 view

ravel이 빠르지만 원본과 메모리를 공유할 수 있어서, 결과를 수정할 거라면 flatten이 안전합니다. view와 copy의 구분은 아래 indexing 절에서 다시 나옵니다.

transpose와 T

선형대수의 transpose와 같은 역할로, 행과 열을 뒤집습니다.

1
2
3
4
m = np.arange(6).reshape(2, 3)   # shape (2, 3)

m.T              # shape (3, 2), view
m.transpose()    # 같은 동작, 역시 view

T와 transpose의 차이는 3차원 이상에서 드러납니다. T는 항상 모든 축을 역순으로 뒤집고, transposet.transpose(0, 2, 1)처럼 축 순서를 직접 지정할 수 있습니다.

indexing과 slicing

indexing

NumPy의 indexing 문법은 Python의 list와 같습니다. 2차원부터는 쉼표로 축을 구분합니다.

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

m[0, 2]       # 2, 0행 2열의 값
m[1]          # 1행 전체
m[-1]         # 마지막 행

Fancy indexing

정수 배열로 원하는 위치를 한 번에 골라냅니다.

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

a[[0, 2, 5]]            # [3 7 2], 0, 2, 5번 element

slicing

1
2
3
m[:, 2]       # 모든 행의 2열, 열 하나 뽑기
m[0:2, 1:3]   # 부분 행렬
m[::2]        # 행을 하나 걸러 하나씩

View와 Copy

view는 원본 메모리를 그대로 같이 쓰는 배열이며, Copy는 데이터를 새 메모리에 옮겨 담은 배열이 copy입니다. view에 값을 쓰면 원본도 바뀌고, copy는 원본과 무관합니다.

indexing 문법은 list와 같지만 동작이 결정적으로 다른 지점이 있습니다. Python list의 slice는 copy이고, NumPy의 slice는 view입니다.

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

대용량 데이터의 복사 비용을 아끼기 위한 설계입니다. 원본과 분리하고 싶으면 명시적으로 copy합니다.

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

NumPy에서 slice는 view, fancy indexing과 boolean indexing은 항상 copy입니다. 흩어진 element를 골라 모으는 작업은 연속된 메모리로 표현할 수 없기 때문입니다. 이 구분은 Pandas 기초 4편에서 chained assignment 문제를 이해하는 밑거름이 됩니다.

Conditional selection

Boolean indexing

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

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

mask = a > 0            # [ True False  True False False  True]
a[mask]                 # [3 7 2]

Multiple conditions

1
2
3
a[(a > 0) & (a < 5)]    # [3 2], 각 조건에 괄호 필수
a[(a < 0) | (a > 5)]    # [-1 7 -5]
a[~(a > 0)]             # [-1 0 -5], 부정

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

np.where

조건에 따라 ndarray의 값을 교체합니다.

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

ndarray 연산

elementwise 연산

산술 연산은 같은 위치의 element끼리 vectorization으로 적용됩니다.

1
2
3
4
5
6
a = np.array([1, 2, 3])
b = np.array([10, 20, 30])

a + b     # [11 22 33]
a * b     # [10 40 90]
a ** 2    # [1 4 9]

Broadcasting

Broadcasting은 shape이 다른 ndarray 간 연산을 위한 규칙입니다. 작은 배열이 큰 배열의 모양에 맞춰 늘어난 것처럼 동작합니다.

1
2
3
4
5
6
m = np.array([[1, 2, 3],
              [4, 5, 6]])       # shape (2, 3)

m + 10                          # [[11 12 13] [14 15 16]], 스칼라가 전체에 적용
m + np.array([10, 20, 30])      # [[11 22 33] [14 25 36]], (3,)이 행마다 적용
m + np.array([[10], [20]])      # [[11 12 13] [24 25 26]], (2, 1)이 열마다 적용

규칙은 하나입니다. 두 배열의 shape를 뒤에서부터 축끼리 비교해서, 크기가 같거나 한쪽이 1이면 호환됩니다. (2, 3)(3,)은 마지막 축이 3으로 같아서 통과하고, (2, 3)(2,)는 마지막 축이 3과 2라 에러가 납니다.

실제로 작은 배열을 복사해서 늘리는 것이 아니라 계산할 때만 그렇게 해석하므로, 메모리 낭비도 없습니다.

elementwise multiplication과 matrix multiplication

*는 같은 위치의 element끼리 곱하는 elementwise 곱이고, @는 선형대수의 행렬 곱(matrix multiplication)입니다.

1
2
3
4
5
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])

A * B     # [[ 5 12] [21 32]], 같은 위치끼리 곱
A @ B     # [[19 22] [43 50]], 행렬 곱

*를 행렬 곱으로 착각하면 에러 없이 틀린 결과가 나오므로 주의해야 합니다.

Aggregation과 axis

sum, mean, min, max

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

m.sum() # 66
m.mean() # 5.5
m.min() # 0
m.max() # 11

argmin, argmax

argmin, argmax는 index를 반환합니다. (여기 멘트 좀 다듬어)

1
2
3
scores = np.array([92, 68, 85, 77])
scores.argmax() # 0
scores.argmin() # 1

axis

Aggregation 함수에 axis를 주면 방향을 정해서 접을 수 있습니다.

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

m.sum(axis=0)   # [12 15 18 21], 행 방향으로 접음, 열별 합
m.sum(axis=1)   # [ 6 22 38], 열 방향으로 접음, 행별 합

지정한 축이 사라진다고 기억하면 헷갈리지 않습니다. (3, 4)에서 axis=0을 접으면 결과는 (4,), axis=1을 접으면 (3,)입니다. 열별 통계가 필요하면 axis=0, 행별 통계가 필요하면 axis=1입니다.

ndarray merge

concatenate

기존의 axis에 따라 ndarray를 이어 붙힙니다.

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

np.concatenate([a,b], axis = 0) # [ [1 2] [3 4] [5 6]
np.concatenate([a,b.T], axis = 1) # [ [ 1 2 5] [ 3 4 6]]

stack

ndarray 위에 새로운 ndarray를 쌓아 만듭니다.

1
2
3
4
x = np.array([1, 2, 3])
y = np.array([4, 5, 6])
np.stack([x, y]) [[1 2 3 ][4 5 6 ]]
np.stack([x, y], axis = 1) [[1 4] [ 2 5] [ 3 6]]

vstack과 hstack

vstack( vertical stakc) hstack( horizonal stack) 자주 쓰는 axis를 기준으로 박아둔 단축형입니다.

1
2
np.vstack([x, y])   # (2, 3), 세로로 쌓기 (행 추가)
np.hstack([x, y])   # (6,), 가로로 잇기

이어 붙이면 concatenate, 쌓아서 차원을 늘리면 stack입니다.

Sort

sort

1
2
3
scores = np.array([92, 68, 85, 77]) 
np.sort(scores) # [68 77 85 92], 복사본을 반환
scores. sort() # 원본을 제자리에서 정렬 

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

argsort

정렬 순서대로의 index을 돌려줍니다. 그 배열이 다른 배열과 나란히 연결되어 있을 때 필요합니다.

1
2
3
4
5
6
scores = np.array([92, 68, 85, 77])
students = np.array(["김철수", "이영희", "박민수", "정수진"])

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

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

NaN 처리

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

isnan

연산에 NaN이 하나라도 끼면 결과가 NaN입니다. 위치 확인은 isnan으로 합니다.

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

a.sum()          # nan, 전체가 NaN이 된다
np.isnan(a)      # [False  True False], 위치 확인

NaN 비교 시 주의점

NaN은 자기 자신과도 같지 않습니다. 그래서 ==로는 NaN을 찾을 수 없습니다.

1
2
3
np.nan == np.nan   # False
a[a == np.nan]     # 빈 배열, 아무것도 못 찾음
a[np.isnan(a)]     # [nan], isnan이 정답

참고로 NaN은 float입니다. 부동소수점 표준에 정의된 특수값이라 정수 배열에는 들어갈 수 없습니다. 이 제약이 pandas에서 일으키는 연쇄 반응(정수 컬럼의 float 승격)은 Pandas 기초 6편의 주요 소재입니다. 그리고 결측 표기가 NaN 하나인 것은 NumPy까지의 이야기입니다. pandas에서는 None, NaT, pd.NA 같은 표기가 더 등장하는데, 이 구분도 Pandas 기초 6편에서 정리합니다. 배열을 만들고, 고르고, 연산하고, 집계하는 문법을 정리했습니다. 다음 시리즈에서는 이 배열 위에 라벨을 붙인 pandas를 다룹니다.

다음 글: Pandas 기초 (0) - 시리즈 소개

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