4.3.2 배열과 배열의 1:1 거울 연산 (Element-wise)

배열 대 배열 연산의 프로그래밍적 의미와 활용

모양(Shape)이 완전히 똑같은 두 그룹(배열)이 1:1로 짝을 지어 하이파이브 연산을 수행!

배열 간의 연산 웹툰 일러스트

앞서 단일 숫자(스칼라)가 거대한 배열 전체에 똑같은 마법을 거는 브로드캐스팅을 배웠습니다.

이번에는 반대로 쌍둥이처럼 크기(Shape)가 정확히 같은 두 배열끼리 만났을 때 벌어지는 일입니다.

수학적 의미: 두 가지 곱셈의 충돌

우리가 수학 시간에 종종 헷갈려 하는 부분이 배열(행렬)의 곱셈입니다.

Numpy 행렬의 세계에는 완전히 성격이 다른 두 가지 방식이 존재합니다.

1. 아다마르 곱 (Hadamard Product, 요소별 연산)

학생 그룹 A와 B가 마주 보고 서서, 자기 바로 맞은편에 있는 짝꿍하고만 정직하게 1:1로 곱하고 더하는 시각적 픽셀 연산입니다.

프로그래밍에서 +, -, *, / 기호를 쓰면 무조건 이 방식이 작동합니다.

2. 내적 (Dot Product, 행렬 곱)

앞 배열의 ‘가로줄(행)’ 전체 팀과 뒤 배열의 ‘세로줄(열)’ 전체 팀이 십자가 모양으로 무식하게 직진 충돌하여 하나의 숫자로 뭉쳐지는 찐 수학적 행렬 연산입니다.

이 연산을 쓰려면 별도의 기호 @를 써야 합니다. (뒤에서 다시 자세히 배웁니다!)

배열 하이파이브 애니메이션 설명

언제 어떤 용도로 사용할까? (실무 활용 사례)

두 데이터 세트 병합 연산

예를 들어 올해와 작년의 ‘월별 매출액 배열’ 두 개가 있을 때, 두 배열을 그냥 올해매출 - 작년매출로 빼주면 단숨에 1월부터 12월까지의 ‘전년대비 매출 증감액 배열’이 새롭게 탄생합니다.

이미지 합성(Blending/Masking)

두 장의 사진(픽셀 배열)을 투명도를 조절해 겹쳐 보이게 하거나(A * 0.5 + B * 0.5), 흑백 마스크 배열 장막(01로 구성)을 곱해(A * Mask) 원하는 부위만 도려낼 때 자주 씁니다.

같은 모양(Shape) 두 원소끼리의 사칙연산 (아다마르 연산)

다음 코드로 똑같은 2행 3열짜리 2차원 배열 ab를 준비합니다.

import numpy as np

# [1단계] 1부터 6까지 채워진 2x3 배열
a = np.arange(1, 7).reshape(2, 3)
a

출력:

array([[1, 2, 3],
       [4, 5, 6]])
# [2단계] 'a'의 거푸집 틀을 빌려와 그 속을 [1, 2, 3] 패턴으로 가득 채운 쌍둥이 크기 배열
b = np.full_like(a, [1, 2, 3])
b

출력:

array([[1, 2, 3],
       [1, 2, 3]])

모양이 정확히 2x3으로 동일한 두 배열은 파서는 기본 사칙 연산 연산자(+, -, *, /)를 만나면, 무조건 같은 인덱스 위치의 맞은편 짝꿍 파트너와 1:1로 요소별 연산(Element-wise)을 냅다 수행합니다.

print("--- 1:1 덧셈 ---")
print(a + b)

print("\n--- 1:1 뺄셈 ---")
print(a - b)

print("\n--- 1:1 곱셈 (아다마르 곱) ---")
print(a * b)

print("\n--- 1:1 나눗셈 ---")
print(a / b)

출력:

--- 1:1 덧셈 ---
[[2 4 6]
 [5 7 9]]

--- 1:1 뺄셈 ---
[[0 0 0]
 [3 3 3]]

--- 1:1 곱셈 (아다마르 곱) ---
[[ 1  4  9]
 [ 4 10 18]]

--- 1:1 나눗셈 ---
[[1.  1.  1. ]
 [4.  2.5 2. ]]

In-place 복합 대입 연산자와 파멸의 함정(Type Error)

Numpy 배열 연산에서도 +=, -=, *=, /= 등의 복합 대입 연산자를 지원하여 메모리를 절약하며 변수 본연의 내용을 덮어버릴 수 있습니다.

하지만, 배열 각각의 자료형 허용치(dtype) 가 다르면 끔찍한 함정에 빠질 수 있습니다.

# 'a'는 정수(int32)들만 출입 가능한 아파트 
a = np.arange(4)
a

출력:

array([0, 1, 2, 3])
# 'b'는 소수점 실수형(float64) 아파트
b = np.arange(0.5, 4, 1)
b

출력:

array([0.5, 1.5, 2.5, 3.5])

다음 코드는 정수 전용인 a 방에 거대한 소수점 데이터인 b를 욱여넣으려(Cast) 시도하다가 방이 좁아 터지는 에러를 발생시킵니다.

# a(정수) 방에 a + b(결과는 실수) 데이터를 밀어넣으려고 시도! -> 실패!
a += b

오류:

UFuncTypeError: Cannot cast ufunc 'add' output from dtype('float64') to dtype('int32') with casting rule 'same_kind'

반대로 넓고 여유로운 실수형 아파트 b에 좁은 정수형 a 데이터를 들여오는 b += a는 문제없이 부드럽게 흡수됩니다.

# b(실수) 방에 실수 결과를 안전하게 밀어넣어 보관!
b += a
b

출력:

array([0.5, 2.5, 4.5, 6.5])

행렬의 곱셈 (Dot Product)

수학에서 말하는 ‘진짜 행렬 단위의 내적(Dot Product) 연산’은 앞 배열의 행(Row)과 뒤 배열의 열(Column) 갯수가 서로 정확히 맞물려 떨어져야 수행할 수 있습니다.

* 기호 대신 앳(@) 연산자나 a.dot(b) 함수 호출을 사용하여 파괴력 있는 십자가 충돌 곱 연산을 수행합니다.

  • (2, 3) 모양의 배열과 (3, 2) 모양의 배열은 안쪽 고리인 3이 서로 맞물려 통과되므로 곱할 수 있으며, 바깥 고리인 (2, 2) 모양의 새 배열이 탄생합니다.
# (2행 3열) 앞 행렬
a = np.full((2, 3), [1, 2, 3])
a

출력:

array([[1, 2, 3],
       [1, 2, 3]])
# (3행 2열) 뒤 행렬
b = np.full((3, 2), [2, 1])
b

출력:

array([[2, 1],
       [2, 1],
       [2, 1]])

두 배열 a, b의 곱 연산은 @ 연산자, 또는 ndarray의 객체 내장 함수 .dot()으로 깔끔하게 수행됩니다.

# 직관적인 골뱅이 연산자 (파이썬 3.5 이후 지원)
a @ b

출력:

array([[12,  6],
       [12,  6]])
# 고전적인 함수 호출 방식
a.dot(b)

출력:

array([[12,  6],
       [12,  6]])

일반적으로 행렬의 곱셈은 순서를 바꾸면 전혀 다른 결과가 나오거나 아예 에러가 납니다(교환법칙 성립 안됨!).

이번엔 반대로 b(3x2) @ a(2x3) 연산을 뒤집어 실행해 보면, 맞물리는 2를 제외하고 (3, 3) 덩어리 모양의 결과가 튀어나오게 됩니다!

# b가 앞으로, a가 뒤로 가면 (3x3)이라는 완전히 새로운 세상이 열립니다
b.dot(a)

출력:

array([[3, 6, 9],
       [3, 6, 9],
       [3, 6, 9]])
서브목차