NumPy 연산: matmul·통계·broadcasting으로 표준화 직접 구현하기
스칼라부터 텐서까지 차원 개념을 정리하고, 행렬곱과 브로드캐스팅 규칙을 이해한다. 이를 이용해 (N,3) 데이터를 열별로 표준화하는 코드를 직접 짜본다. 이 브로드캐스팅과 배치(batch) 차원 개념은 앞으로 신경망 코드 어디서나 계속 마주치게 된다.
오늘의 목표
오늘은 두 가지를 할 수 있으면 된다. 첫째, np.matmul(행렬곱)과 mean·std 같은 통계 함수를 언제 어떻게 쓰는지 안다. 둘째, 크기가 다른 배열끼리 왜, 어떻게 연산이 되는지(브로드캐스팅) 직접 규칙을 눈으로 확인한다. 이 두 가지가 합쳐지면 "데이터 표준화(standardization)"라는, 딥러닝 어디서나 나오는 전처리를 직접 만들 수 있다.
개념과 직관
스칼라(scalar)·벡터(vector)·행렬(matrix)·텐서(tensor)
- 스칼라: 숫자 하나. 예)
5 - 벡터: 숫자를 한 줄로 늘어놓은 것. 예)
[1, 2, 3]— 1차원 배열 - 행렬: 숫자를 표(row × column)로 늘어놓은 것. 예) 3행 4열 — 2차원 배열
- 텐서: 위 개념을 일반화해서 차원이 3개 이상인 것까지 포함하는 말. NumPy에서는 몇 차원이든 다
ndarray라는 한 가지 타입으로 다룬다.
비유하자면 스칼라는 온도계 숫자 하나, 벡터는 한 사람의 키·몸무게·나이를 담은 한 줄, 행렬은 여러 사람의 그 정보를 모은 표, 텐서는 그 표가 여러 장 쌓인 것(예: 요일별 표)이라고 생각하면 된다.
행렬곱(matmul)과 내적(dot)
내적(dot product)은 벡터 두 개를 각 위치끼리 곱해서 다 더하는 연산이다. 벡터 [1,2,3]과 [4,5,6]의 내적은 1*4 + 2*5 + 3*6 = 32. "두 벡터가 얼마나 같은 방향을 가리키는지"를 숫자 하나로 요약한다고 볼 수 있다.
행렬곱은 이 내적을 표 전체에 대해 반복하는 것이다. (M,K) 모양 행렬과 (K,N) 모양 행렬을 곱하면 (M,N) 모양이 나온다. 여기서 두 행렬의 가운데 숫자(K)가 같아야 곱셈이 성립한다. NumPy에서는 np.matmul(A, B) 또는 A @ B로 쓴다. np.dot도 비슷하지만, 2차원 이상에서는 matmul이 더 명확하고 신경망 코드에서 관례적으로 많이 쓰인다.
통계 함수: mean·std·sum·max·argmax
mean: 평균. 데이터가 대략 어디쯤 몰려있는지std(표준편차, standard deviation): 데이터가 평균에서 얼마나 흩어져 있는지sum: 다 더한 값max: 가장 큰 값argmax: 가장 큰 값이 "몇 번째 위치"에 있는지 (값이 아니라 인덱스를 돌려준다는 점이 중요)
이 함수들은 전부 axis(축)라는 매개변수를 받는다. axis=0은 "행 방향으로 뭉개서" 즉 열마다 하나씩 계산, axis=1은 "열 방향으로 뭉개서" 즉 행마다 하나씩 계산한다는 뜻이다. 표를 세워서 보느냐 눕혀서 보느냐의 차이라고 생각하면 된다.
브로드캐스팅(broadcasting)
모양이 다른 배열끼리 연산할 때 NumPy가 자동으로 작은 쪽을 "복제해서" 크기를 맞춰주는 규칙이다. 규칙은 뒤 차원부터 비교하면서, 두 차원의 크기가 같거나 둘 중 하나가 1이면 통과, 아니면 에러다.
예를 들어 (3,1) 모양과 (1,4) 모양을 더하면:
- 뒤에서 첫 번째 차원: 1 vs 4 → 하나가 1이니 4로 맞춰짐
- 뒤에서 두 번째 차원: 3 vs 1 → 하나가 1이니 3으로 맞춰짐
- 결과: (3,4)
머릿속 그림으로는, (3,1)짜리 세로줄을 옆으로 4번 복사해서 (3,4) 표를 만들고, (1,4)짜리 가로줄을 아래로 3번 복사해서 (3,4) 표를 만든 다음, 둘을 위치별로 더하는 것과 똑같은 결과가 나온다. 실제로 메모리에 복사가 일어나지는 않지만(효율을 위해), 결과만 보면 그렇게 이해하면 된다.
코드로 직접 해보기
1단계: 행렬곱 감 잡기
import numpy as np
A = np.array([[1, 2, 3],
[4, 5, 6]]) # shape (2,3)
B = np.array([[1, 0],
[0, 1],
[1, 1]]) # shape (3,2)
C = A @ B # (2,3) @ (3,2) -> (2,2)
print(C.shape)
print(C)2단계: 열별 통계 계산
data = np.array([[10., 2., 100.],
[12., 4., 200.],
[14., 6., 300.],
[16., 8., 400.]]) # shape (4,3), 즉 4개 샘플, 3개 특성
col_mean = data.mean(axis=0) # shape (3,) — 열(특성)마다 평균
col_std = data.std(axis=0) # shape (3,) — 열마다 표준편차
print(col_mean, col_std)axis=0을 쓴 이유: 4개의 행(샘플)을 뭉개서 3개의 열(특성) 각각에 대한 평균을 얻고 싶기 때문이다.
3단계: broadcasting으로 열별 표준화
표준화 공식(텍스트 수식): 표준화된 값 = (원래값 - 열평균) / 열표준편차
standardized = (data - col_mean) / col_std
print(standardized.shape) # (4,3) — 원본과 같은 모양
print(standardized.mean(axis=0)) # 거의 0에 가까워야 함
print(standardized.std(axis=0)) # 거의 1에 가까워야 함여기서 data는 (4,3)인데 col_mean은 (3,)이다. 모양이 다른데도 뺄셈이 되는 이유가 바로 브로드캐스팅이다. (3,)은 (1,3)으로 취급되고, 앞의 차원 1이 4로 늘어나면서 (4,3)과 맞춰진다.
4단계: 두 벡터 집합의 pairwise 내적
"pairwise 내적"이란 집합 X의 벡터 하나하나와 집합 Y의 벡터 하나하나를 전부 짝지어서 내적을 구하는 것이다. 이걸 반복문 없이 행렬곱 한 번으로 끝낼 수 있다.
X = np.array([[1., 0.],
[0., 1.],
[1., 1.]]) # 3개의 2차원 벡터, shape (3,2)
Y = np.array([[2., 0.],
[0., 2.],
[1., 1.],
[1., 0.]]) # 4개의 2차원 벡터, shape (4,2)
pairwise = X @ Y.T # (3,2) @ (2,4) -> (3,4)
print(pairwise)
# pairwise[i, j] 는 X의 i번째 벡터와 Y의 j번째 벡터의 내적Y.T는 Y를 전치(transpose, 행과 열을 뒤바꾸는 것)한 것이다. X가 (3,2), Y.T가 (2,4)라서 가운데 2가 맞아떨어져 행렬곱이 성립하고, 결과는 모든 조합의 내적을 담은 (3,4) 표가 된다. 이 패턴은 나중에 "이 문장과 저 단어가 얼마나 관련 있는가"를 점수로 계산하는 데서 형태 그대로 다시 나온다.
오늘의 실험
아래 코드를 실행해서 (3,1)과 (1,4)를 더했을 때 왜 (3,4)가 나오는지 눈으로 확인해라.
a = np.array([[1], [2], [3]]) # shape (3,1)
b = np.array([[10, 20, 30, 40]]) # shape (1,4)
result = a + b
print(result.shape) # (3,4) 인지 확인
print(result)결과 행렬을 손으로도 한 번 계산해보고 (예: result[0,0]은 1+10=11, result[2,3]은 3+40=43) 코드 출력과 맞는지 대조한다. 그다음 아래처럼 모양을 바꿔가며 무슨 일이 일어나는지, 혹은 에러가 나는지 기록해라.
(3,1)과(1,4)→ 성공, (3,4)(3,2)와(1,4)→ 에러 발생 여부 확인, 왜 안 되는지 브로드캐스팅 규칙으로 설명해보기(4,)와(3,1)→ 결과 모양이 뭐가 나오는지, 왜 그런지(3,)과(3,1)→ 위와 비교했을 때 뭐가 다른지
노트에는 "입력 모양 → 결과 모양 (또는 에러 메시지) → 왜 그런지 한 줄 설명" 형태로 표를 만들어 기록하는 걸 추천한다.
왜 이걸 배우나 (LLM / Post-training 연결)
신경망의 거의 모든 연산은 결국 행렬곱과 브로드캐스팅 덧셈의 반복이다. 오늘 만든 "pairwise 내적" 계산은 나중에 어텐션(attention) 메커니즘에서 "이 토큰이 저 토큰을 얼마나 봐야 하는가"를 계산하는 연산과 형태가 똑같다. 그리고 열별 표준화는 배치 정규화(batch normalization)나 레이어 정규화(layer normalization) 같은, 학습을 안정시키는 기법의 뼈대가 된다. Post-training 실험을 할 때 데이터 전처리나 임베딩 유사도 계산에서 이런 broadcasting 감각이 없으면 shape 에러를 디버깅하는 데 시간을 다 쓰게 된다.
초보자가 막히는 지점
- (3,)과 (3,1)을 같은 것으로 착각한다.
(3,)은 진짜 1차원 벡터고,(3,1)은 2차원인데 열이 1개인 행렬이다. 브로드캐스팅 결과가 다르게 나올 수 있으니.shape를 항상 찍어서 확인하는 습관을 들여라. - axis 방향을 반대로 착각한다.
axis=0이 "행끼리 계산"인지 "열끼리 계산"인지 헷갈리면, 항상 "axis=0을 쓰면 그 축의 길이가 결과에서 사라진다"고 외워라. (N,3)에서 axis=0으로 mean을 내면 결과는 (3,)이 된다. - 행렬곱과 원소별 곱셈(element-wise multiplication)을 혼동한다.
A * B는 같은 위치끼리 곱하는 것(모양이 같거나 브로드캐스팅 가능해야 함)이고,A @ B는 완전히 다른 연산이다. 둘을 바꿔 쓰면 에러가 나거나, 더 위험하게는 에러 없이 조용히 틀린 결과가 나올 수 있다. - 브로드캐스팅 에러 메시지를 무서워한다.
operands could not be broadcast together with shapes ...라는 에러가 뜨면 당황하지 말고 두 shape를 뒤에서부터 나란히 적어 어느 차원에서 안 맞는지 손으로 짚어보면 대부분 바로 원인이 보인다.
알고리즘 (병행 1시간)
오늘은 LeetCode Easy 1문제 + Medium(수학 카테고리) 1문제를 푼다. 문제 번호나 제목은 스스로 "Array" 또는 "Math" 태그에서 골라도 되고, 강사가 지정한 문제가 있다면 그것을 우선한다.
풀이 후 반드시 아래 세 가지를 기록해라.
- 접근: 어떤 아이디어로 풀었는지 한두 문장 (예: "투 포인터로 양 끝에서 좁혀갔다")
- 시간복잡도: Big-O로 표현 (예: O(n), O(n log n)) — 왜 그런 복잡도가 나오는지 한 줄 설명 포함
- 틀린 이유: 처음에 틀렸다면 어떤 엣지 케이스나 오해 때문이었는지. 맞았다면 "한 번에 통과" + 어떤 부분이 헷갈렸는지 적어도 좋다
이 기록은 나중에 비슷한 유형을 다시 만났을 때 "아 이거 저번에 헷갈렸던 패턴이다"를 빨리 알아채게 해준다.