Skip to content

NumPy 행렬 곱셈: np.dot, matmul, @ 완전 가이드

Updated on

행렬 곱셈은 머신러닝, 컴퓨터 그래픽스, 신호 처리, 과학 컴퓨팅의 기초입니다. 하지만 NumPy는 행렬을 곱하는 세 가지 방법 -- np.dot(), np.matmul(), @ 연산자 -- 을 제공하며, 이들 간의 차이점은 혼란스럽습니다. 잘못된 것을 사용하면 특히 서로 다른 차원의 배열로 작업하거나 요소별 곱셈(*)과 행렬 곱셈을 혼동할 때 조용히 잘못된 결과를 생성할 수 있습니다.

이 가이드는 각 방법을 명확히 하고, 언제 어떤 것을 사용해야 하는지 보여주며, 모든 일반적인 선형 대수 패턴에 대한 실용적인 예제를 제공합니다.

📚

요소별 곱셈 vs 행렬 곱셈

먼저, 중요한 구분입니다. * 연산자는 요소별 곱셈을 수행하며, 행렬 곱셈이 아닙니다:

import numpy as np
 
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
 
# 요소별 곱셈 (아다마르 곱)
print(A * B)
# [[ 5 12]
#  [21 32]]
 
# 행렬 곱셈
print(A @ B)
# [[19 22]
#  [43 50]]

행렬 곱셈은 규칙을 따릅니다: C[i,j] = sum(A[i,k] * B[k,j]) (모든 k에 대해).

@ 연산자 (권장)

@ 연산자 (Python 3.5에서 도입)는 행렬 곱셈을 수행하는 가장 깔끔하고 읽기 쉬운 방법입니다:

import numpy as np
 
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
 
C = A @ B
print(C)
# [[19 22]
#  [43 50]]

Shape 규칙

행렬 곱셈 A @ B가 작동하려면 A의 열 수가 B의 행 수와 같아야 합니다:

import numpy as np
 
# (2, 3) @ (3, 4) = (2, 4)
A = np.ones((2, 3))
B = np.ones((3, 4))
C = A @ B
print(C.shape)  # (2, 4)
 
# (5, 3) @ (3, 2) = (5, 2)
A = np.random.randn(5, 3)
B = np.random.randn(3, 2)
C = A @ B
print(C.shape)  # (5, 2)
 
# 불일치는 ValueError 발생
# np.ones((2, 3)) @ np.ones((4, 2))  # ValueError

np.matmul()

np.matmul()@와 정확히 같은 것을 합니다. 모든 실용적인 목적에서 동등합니다:

import numpy as np
 
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
 
# 이들은 동일한 결과를 생성
result1 = A @ B
result2 = np.matmul(A, B)
 
print(np.array_equal(result1, result2))  # True

더 깔끔한 코드에는 @를 사용하세요. 함수 인수로 전달해야 할 때(예: reduce)는 np.matmul()을 사용하세요.

np.dot()

np.dot()은 더 오래된 것으로, 고차원 배열에서 @/matmul과 다르게 동작합니다:

import numpy as np
 
# 1D 배열: 내적 (스칼라 결과)
a = np.array([1, 2, 3])
b = np.array([4, 5, 6])
print(np.dot(a, b))  # 32 (1*4 + 2*5 + 3*6)
 
# 2D 배열: 행렬 곱셈 (@와 동일)
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
print(np.dot(A, B))
# [[19 22]
#  [43 50]]

핵심 차이점: 고차원

2차원 이상의 배열에서 np.dot()@는 다르게 동작합니다:

import numpy as np
 
A = np.random.randn(2, 3, 4)
B = np.random.randn(2, 4, 5)
 
# @는 행렬 곱셈의 배치로 처리: (2, 3, 4) @ (2, 4, 5) = (2, 3, 5)
result_matmul = A @ B
print(result_matmul.shape)  # (2, 3, 5)
 
# np.dot은 다른 브로드캐스팅 사용: A의 마지막 축과 B의 끝에서 두 번째에 대해 합산
result_dot = np.dot(A, B)
print(result_dot.shape)  # (2, 3, 2, 5) -- 다른 형태!

권장: 행렬 곱셈에는 @ 또는 np.matmul()을 사용하세요. np.dot()은 1D 벡터의 명시적 내적에만 사용하세요.

메서드 비교

특성@ 연산자np.matmul()np.dot()* 연산자
연산행렬 곱셈행렬 곱셈내적요소별
1D x 1D내적 (스칼라)내적 (스칼라)내적 (스칼라)요소별
2D x 2D행렬 곱셈행렬 곱셈행렬 곱셈요소별
N-D 배치배치 matmul배치 matmul다른 시맨틱스요소별
가독성최고좋음보통N/A
스칼라 허용아니오아니오
권장예 (함수형)1D 내적만아다마르용

내적 (벡터)

두 벡터의 내적은 요소별 곱의 합입니다:

import numpy as np
 
a = np.array([1, 2, 3])
b = np.array([4, 5, 6])
 
# 1D 벡터에서 모두 동등
print(np.dot(a, b))    # 32
print(a @ b)            # 32
print(np.sum(a * b))    # 32

응용

import numpy as np
 
# 코사인 유사도
def cosine_similarity(a, b):
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
 
vec1 = np.array([1, 2, 3])
vec2 = np.array([4, 5, 6])
print(f"Cosine similarity: {cosine_similarity(vec1, vec2):.4f}")
# 0.9746
 
# a를 b 위에 투영
def project(a, b):
    return (np.dot(a, b) / np.dot(b, b)) * b
 
print(project(np.array([3, 4]), np.array([1, 0])))
# [3. 0.]

행렬-벡터 곱셈

import numpy as np
 
# 선형 변환
A = np.array([[2, 0], [0, 3]])  # 스케일링 행렬
v = np.array([1, 1])
 
result = A @ v
print(result)  # [2 3]
 
# 신경망 계층에서 가중치 적용
weights = np.random.randn(10, 5)  # 10 출력, 5 입력
inputs = np.random.randn(5)
output = weights @ inputs
print(output.shape)  # (10,)

배치 행렬 곱셈

딥러닝과 배치 처리에서 @는 배치를 자동으로 처리합니다:

import numpy as np
 
# 32개 행렬의 배치, 각 4x3, 곱하기 32개 행렬의 배치, 각 3x5
batch_A = np.random.randn(32, 4, 3)
batch_B = np.random.randn(32, 3, 5)
 
result = batch_A @ batch_B
print(result.shape)  # (32, 4, 5)

실용적인 예제

연립일차방정식

import numpy as np
 
# Ax = b 풀기
# 2x + 3y = 8
# 4x + y = 10
A = np.array([[2, 3], [4, 1]])
b = np.array([8, 10])
 
x = np.linalg.solve(A, b)
print(f"x = {x[0]:.2f}, y = {x[1]:.2f}")
# x = 2.20, y = 1.20
 
# 검증: A @ x는 b와 같아야 함
print(A @ x)  # [8. 10.]

간단한 신경망 순방향 전파

import numpy as np
 
def sigmoid(x):
    return 1 / (1 + np.exp(-x))
 
# 네트워크: 3 입력 -> 4 은닉 -> 2 출력
np.random.seed(42)
W1 = np.random.randn(4, 3) * 0.5  # 첫 번째 층 가중치
b1 = np.zeros(4)
W2 = np.random.randn(2, 4) * 0.5  # 두 번째 층 가중치
b2 = np.zeros(2)
 
# 순방향 전파
X = np.array([1.0, 0.5, -1.0])  # 입력
h = sigmoid(W1 @ X + b1)         # 은닉층
y = sigmoid(W2 @ h + b2)         # 출력층
 
print(f"Input:  {X}")
print(f"Hidden: {h}")
print(f"Output: {y}")

PCA 투영

import numpy as np
 
# 샘플 데이터 생성
np.random.seed(42)
data = np.random.randn(100, 5)  # 100 샘플, 5 특성
 
# 데이터 중심화
data_centered = data - data.mean(axis=0)
 
# 행렬 곱셈으로 공분산 행렬 계산
cov = (data_centered.T @ data_centered) / (len(data) - 1)
 
# 고유값 분해
eigenvalues, eigenvectors = np.linalg.eigh(cov)
 
# 상위 2개 주성분으로 투영
top_2 = eigenvectors[:, -2:]  # 마지막 2개 (최대 고유값)
projected = data_centered @ top_2
print(f"Original shape: {data.shape}")
print(f"Projected shape: {projected.shape}")  # (100, 2)

행렬 연산 시각화

행렬 변환이 데이터에 미치는 효과를 탐색하기 위해, PyGWalker (opens in a new tab)를 사용하면 Jupyter에서 투영된 데이터를 인터랙티브하게 시각화할 수 있습니다:

import pandas as pd
import pygwalker as pyg
 
df = pd.DataFrame(projected, columns=['PC1', 'PC2'])
walker = pyg.walk(df)

FAQ

np.dot과 np.matmul의 차이점은 무엇인가요?

1D 및 2D 배열에서는 같은 결과를 생성합니다. 핵심 차이점은 고차원 배열에 있습니다: np.matmul()(및 @)은 행렬의 배치로 처리하고, np.dot()은 다른 브로드캐스팅 규칙을 사용합니다. 새로운 코드에는 @ 또는 np.matmul()을 선호하세요.

NumPy에서 @ 연산자는 무엇을 하나요?

@ 연산자는 np.matmul()과 동등한 행렬 곱셈을 수행합니다. Python 3.5에서 도입되었습니다. 2D 배열에서 A @ B는 표준 행렬곱을 계산합니다. 고차원 배열에서는 배치 행렬 곱셈을 수행합니다.

A * B와 A @ B의 결과가 다른 이유는 무엇인가요?

A * B는 대응하는 요소를 곱하는 요소별 곱셈(아다마르 곱)을 수행합니다. A @ BC[i,j] = sum(A[i,k] * B[k,j]) 규칙에 따라 행렬 곱셈을 수행합니다. 이들은 근본적으로 다른 연산입니다.

NumPy에서 두 벡터의 내적을 어떻게 계산하나요?

1D 배열(벡터)의 경우 np.dot(a, b), a @ b, 또는 np.sum(a * b)를 사용합니다. 세 가지 모두 동일한 스칼라 결과를 반환합니다: 요소별 곱의 합.

행렬 곱셈에 필요한 shape는 무엇인가요?

A @ B에서 A의 마지막 차원은 B의 끝에서 두 번째 차원과 같아야 합니다. 2D의 경우: (m, n) @ (n, p) = (m, p). A의 열 수가 B의 행 수와 같아야 합니다.

결론

NumPy에서 행렬 곱셈에는 @ 연산자를 기본으로 사용하세요 -- 가장 읽기 쉽고 배치 연산을 올바르게 처리합니다. np.dot()은 명시적인 1D 벡터 내적에만 사용하세요. *(요소별)와 @(행렬 곱셈)를 절대 혼동하지 마세요. shape 규칙을 기억하세요: (m, n) @ (n, p) = (m, p), 그러면 가장 일반적인 행렬 곱셈 오류를 피할 수 있습니다.

📚