[ENAI607] Lecture 06 — NumPy 배열과 Matplotlib

SERIES: ENAI607 Python(6)

  1. [ENAI607] Lecture 02 — 리스트, 튜플과 문자열 조작
  2. [ENAI607] Lecture 03 — 딕셔너리, 제어문, 함수와 lambda
  3. [ENAI607] Lecture 04 — 예외 처리, 모듈, 파일 입출력과 with
  4. [ENAI607] Lecture 05 — 클래스, 상속과 센서 객체 만들기
  5. [ENAI607] Lecture 06 — NumPy 배열과 Matplotlib
  6. [ENAI607] 연습문제 Set A — 파일 처리, 상속, NumPy 풀이
이 글의 목차

500개의 x 좌표에서 함수를 한 번에 계산하고, 그중 최댓값의 위치를 찾고, 일부 값을 행렬로 바꾸고, 복소 난수 10,000개의 크기를 히스토그램으로 그리고 싶다. NumPy는 이런 계산을 배열 전체에 적용하는 식으로 표현하게 해 준다. Matplotlib은 그 결과를 그림으로 보여 준다.

이 글은 배열의 기본 문법부터 선형대수·난수·그래프까지 설명하고, 예상문제 3번을 하나의 실행 프로그램으로 완성한다. 단순히 함수 이름을 외우기보다 모양(shape), 원본 데이터 공유 여부, 계산의 종류, 그래프의 축 의미를 구분하면서 읽는 것이 목표다. 이 네 가지를 확인하면 숫자와 조건이 달라진 응용 문제에서도 필요한 코드를 고르기 쉬워진다.

읽는 순서: 처음이면 1–17절로 개념과 짧은 예제를 익힌 뒤 18–21절에서 전체 문제를 풀어 본다. 막혔을 때는 아래 목차나 블로그 검색에서 reshape, axis, argmax, normal, density, solve처럼 함수 이름을 찾는다. 22절에는 조건이 바뀌었을 때의 대응법, 23절에는 오류별 해결표를 모았다.

짧은 python 블록은 필요한 import를 포함한 독립 예제다. text는 명령·문법 틀·출력이다. 전체 풀이와 테스트 파일은 글 끝에서 다운로드할 수 있다. 코드는 Python 3.14, NumPy 2.5, Matplotlib 3.11 환경에서 실행해 확인했다. 난수는 seed를 지정하지 않으면 값이 바뀌며, 출력의 공백·기본 정수 dtype·마지막 소수 자리는 환경에 따라 다를 수 있다.

빠른 목차 — 작업과 함수 이름으로 찾기

찾는 작업 / 함수바로가기
핵심 식을 빠르게 복사하기0. 치트시트
리스트와 배열의 +, * 차이1. NumPy와 벡터화
ndim, shape, size, len, dtype2. 배열 속성
array, zeros, ones, arange, linspace, diag, eye3. 배열 생성
dtype 지정, astype, 정수 배열에 실수 대입4. 자료형
sin, cos, exp, sqrt, 라디안, 부동소수점5. 원소별 함수
서로 다른 shape를 더할 수 있는가6. broadcasting
a[r, c], a[-2:, :2], 인덱스와 범위7. 인덱싱·슬라이싱
원본까지 바뀌는 이유, .copy(), shares_memory8. view와 copy
reshape, -1, .T, 1차원 행·열 벡터9. 모양 변경
sum, mean, var, std, axis, ddof10. 통계
max와 argmax, 최댓값의 x 좌표11. 값과 위치
조건으로 고르기, &, where, any, all12. 마스크
*와 @, dot, solve, inv, det, allclose13. 선형대수
rand, randn, normal, randint, seed14. 난수
1j, np.abs, Gaussian → Rayleigh15. 복소수와 크기
plot, scatter, bar, 범례·색·선 종류16. 그래프 기본
hist, bins, counts·상대도수·밀도, subplot·savefig17. 히스토그램과 저장
예상문제 3번의 요구사항18. 문제 설계
전체 실행 파일19. 전체 풀이
전체 코드의 계산 순서와 함정20. 풀이 해설
자동 테스트21. 검증
입력 크기·함수·행렬·분포가 달라지면22. 응용 대응표
오류 메시지·자주 틀리는 코드23. 문제 해결표
스스로 예상해 볼 질문24. 확인 문제

0. 바로 찾아 쓰는 치트시트

아래는 필요한 문장을 모은 문법 틀이다. 전체를 그대로 실행하기보다 변수에 맞는 식을 골라 쓴다.

import numpy as np
import matplotlib.pyplot as plt

a = np.array([[1, 2], [3, 4]], dtype=float)
a.ndim, a.shape, a.size, a.dtype
x = np.linspace(0, 10, 500)           # 끝점 포함, 개수 500
y = np.sin(3*x) * np.exp(-x/2)        # 반복문 없이 원소별 계산

block = a[1:, :2]                    # 기본 슬라이스: view
independent = a[1:, :2].copy()        # 독립 복사
matrix = y[:12].reshape(3, 4).copy()  # 실험이 y를 바꾸지 않게 분리
matrix.T                            # 행·열 전환

y.mean(), y.std()                    # 기본 ddof=0
i = np.argmax(y)                     # 값이 아니라 인덱스
x_at_max = x[i]
selected = y[(y > 0) & (y < 1)]

solution = np.linalg.solve(A, b)
np.allclose(A @ solution, b)

sigma = 1 / np.sqrt(2)
real = np.random.normal(0, sigma, 10_000)
imag = np.random.normal(0, sigma, 10_000)
magnitude = np.abs(real + 1j*imag)

fig, axes = plt.subplots(2, 1)
axes[0].plot(x, y, label="curve")
axes[0].legend()
axes[1].hist(magnitude, bins=100, density=False)
fig.tight_layout()
fig.savefig("simulation.png")        # show보다 먼저 저장
plt.show()

특히 기억: (500,)는 1차원, (500, 1)은 2차원이다. 기본 슬라이싱은 보통 원본을 공유한다. *는 원소별 곱이고 @는 행렬 곱이다. argmax()는 좌표가 아니라 위치를 준다. normal의 두 번째 인자는 분산이 아니라 표준편차다. 횟수 히스토그램에는 weights=1/N이나 density=True를 붙이지 않는다.

실행 준비

프로젝트에서 선택한 Python 인터프리터에 라이브러리가 없으면 터미널에서 설치한다.

python -m pip install numpy matplotlib

VS Code·PyCharm의 실행 인터프리터와 설치에 사용한 Python이 같아야 한다. 파일 이름을 numpy.py, matplotlib.py로 만들면 실제 라이브러리를 가릴 수 있으므로 다른 이름을 쓴다.

1. NumPy 배열과 벡터화 — 리스트 계산과 다르다

ndarray는 다차원 배열이다. 수치 계산에서는 보통 같은 dtype의 숫자를 저장하며, 연산을 배열 전체에 적용한다.

import numpy as np

values = [1, 2, 3]
a = np.array(values)

print(values * 2)  # [1, 2, 3, 1, 2, 3]: 리스트 반복
print(a * 2)       # [2 4 6]: 원소마다 곱함
print(values + values)  # [1, 2, 3, 1, 2, 3]: 리스트 연결
print(a + a)            # [2 4 6]: 원소별 덧셈
print(a ** 2)           # [1 4 9]

벡터화는 반복 계산을 배열 식으로 표현하는 방식이다. Python에서 원소마다 for를 도는 대신 np.sin(x)처럼 배열을 전달한다. NumPy 내부에서는 최적화된 연산을 수행한다. “벡터화하면 언제나 몇 배 빠르다”는 고정 보장은 없지만, 이 수업의 대량 수치 연산에는 잘 맞는다.

예상문제의 “벡터화하라”는 조건에서는 리스트 comprehension으로 값들을 하나씩 계산한 뒤 배열로 바꾸는 것도 피한다. np.vectorize() 역시 일반 Python 함수를 편하게 반복 적용하는 도구이지, 그 이름만으로 빠른 배열 연산이 되는 것은 아니다.

2. ndim, shape, size, len, dtype

import numpy as np

a = np.array([[0., 1., 2.], [3., 4., 5.]])
print(a.ndim)   # 2: 축이 두 개
print(a.shape)  # (2, 3): 2행 3열
print(a.size)   # 6: 전체 원소 수
print(len(a))   # 2: 첫 번째 축의 길이
print(a.dtype)  # float64

v = np.array([2, 4, 6, 0])
print(v.shape)  # (4,)
속성 / 함수질문(3, 4) 배열의 답
ndim축이 몇 개인가2
shape각 축의 길이는 얼마인가(3, 4)
size전체 원소가 몇 개인가12
len(a)첫 축 길이는 얼마인가3
dtype각 원소를 어떤 타입으로 저장하는가int64, float64 등

shape는 튜플이다. (4,)의 쉼표는 원소가 하나인 튜플이라는 뜻이다. 1차원 배열은 행 벡터나 열 벡터라는 2차원 모양을 아직 가지지 않는다. (4,), (1, 4), (4, 1)을 구분한다.

3행 4열 배열에서 행 1·2와 열 0·1을 선택하면 2행 2열 배열이 되는 도식

그림의 초록 부분이 A[1:3, 0:2]다. 첫 인자는 행, 둘째 인자는 열을 선택한다.

그림 오른쪽 아래의 두 합계는 잘라낸 (2, 2) 배열이 아니라 왼쪽 원본 A 전체를 더한 값이다. axis=0이면 같은 열의 숫자를 세로로 더하고, axis=1이면 같은 행의 숫자를 가로로 더한다. 어떤 숫자끼리 더하는지는 10.2절의 색상별 계산 그림에서 확인할 수 있다.

3. 배열 생성 — 개수와 간격을 구분하기

3.1 array, zeros, ones

import numpy as np

print(np.array([1, 2, 3]))       # [1 2 3]
print(np.array((1.0, 2.0)))      # [1. 2.]
print(np.zeros(3))              # [0. 0. 0.]
print(np.ones((2, 3), dtype=int))
# [[1 1 1]
#  [1 1 1]]

np.zeros((2, 3))의 shape는 튜플 하나로 전달한다. np.zeros(2, 3)처럼 쓰면 “2행 3열”로 해석되지 않는다. 중첩 리스트의 각 행 길이가 달라지는 것도 일반적인 숫자 2차원 배열로 바로 만들 수 없으므로 행 길이를 맞춘다.

3.2 arange는 간격, linspace는 개수

import numpy as np

print(np.arange(5))          # [0 1 2 3 4]
print(np.arange(2, 8, 2))    # [2 4 6]
print(np.linspace(0, 1, 5))  # [0.   0.25 0.5  0.75 1.  ]

x = np.linspace(0, 10, 500)
print(x.shape)                # (500,)
print(x[0], x[-1])            # 0.0 10.0
print(np.isclose(x[1] - x[0], 10 / 499))  # True
함수세 번째 인자끝점
np.arange(start, stop, step)한 번에 증가하는 간격보통 stop 제외
np.linspace(start, stop, num)전체 점 개수기본 stop 포함

0부터 10까지 양 끝을 포함한 점 500개에는 구간이 499개 있다. 간격은 10/499다. np.linspace(0, 10, 0.1)에서 0.1은 간격이 아니라 개수 자리에 들어가므로 맞지 않는다.

실수 간격의 arange는 부동소수점 반올림 때문에 원소 개수나 끝부분이 예상과 다를 수 있다. 정확한 점 개수와 양 끝점이 주어졌으면 linspace를 선택한다. 끝점을 빼고 같은 개수의 점이 필요하면 endpoint=False를 쓴다.

3.3 diag와 eye

import numpy as np

print(np.diag([1., 2., 3.]))
# [[1. 0. 0.]
#  [0. 2. 0.]
#  [0. 0. 3.]]
print(np.eye(2))
# [[1. 0.]
#  [0. 1.]]
print(np.diag(np.array([[1, 2], [3, 4]])))  # [1 4]

np.diag에 1차원 값을 주면 대각 행렬을 만들고, 2차원 배열을 주면 대각 원소를 추출한다. np.eye(n)은 n행 n열 단위행렬을 만든다.

4. dtype과 astype — 값이 소수인데 정수로 저장되면

import numpy as np

a = np.array([1, 2, 3], dtype=np.int64)
a[0] = 1.9
print(a)                 # [1 2 3]: 정수 저장소에서 소수부 손실

b = a.astype(np.float64)
b[0] = 1.9
print(b)                 # [1.9 2.  3. ]
print(b.dtype)           # float64
print(a)                 # [1 2 3]: astype 결과는 b에 받음

기본 정수 dtype이 항상 int32라고 외우지 않는다. NumPy 버전과 플랫폼에 따라 달라질 수 있다. 비트 수가 중요하면 np.int32, np.int64, np.float64처럼 명시한다. 작은 정수 타입에는 표현 범위가 있으므로 큰 값의 계산에서 overflow도 고려해야 한다.

astype는 반올림 함수가 아니다. 정수로 변환하면 일반적으로 소수부가 0 쪽으로 잘린다. np.round로 반올림할지, 정수 dtype으로 바꿀지는 목적이 다르다. a.astype(float)라고 호출만 하고 반환값을 버리면 원래 a가 자동으로 바뀌지 않는다.

5. 원소별 계산과 수학 함수

import numpy as np

x = np.array([0., 1., 4.])
print(np.sqrt(x))          # [0. 1. 2.]
print(np.exp(np.array([0., 1.])).round(4))  # [1.     2.7183]
print(np.sin(np.array([0., np.pi/2, np.pi])).round(6))
# [0. 1. 0.]

t = np.linspace(0, 10, 500)
f = np.sin(3*t) * np.exp(-t/2)
print(f.shape)            # (500,)

np.sin, np.cos, np.sqrt, np.exp, np.abs 같은 함수는 원소별 계산을 지원한다. math.sin은 이런 배열 전체를 그대로 처리하는 용도로 쓰지 않는다. np.exp(x)는 exe^x다. np.e ** x로도 표현할 수 있지만 np.exp가 의도를 분명히 보여 준다.

삼각함수 입력은 라디안이다. 각도가 도 단위이면 np.deg2rad(degrees)로 바꾼다. sin(90)을 계산한다고 1이 나오는 것은 아니다.

import numpy as np

print(np.sin(np.deg2rad(90)))  # 1.0
value = np.sin(np.pi)
print(np.isclose(value, 0))   # True
print(np.allclose([0.1 + 0.2], [0.3]))  # True

컴퓨터의 실수 연산에서는 수학적으로 0인 값이 1.22e-16처럼 아주 작은 수로 나타날 수 있다. e-16은 10−1610^{-16} 배라는 뜻이다. 계산 결과를 검증할 때 ==만 사용하면 반올림 오차 때문에 실패할 수 있어 isclose나 allclose를 사용한다. 비교 허용 오차가 중요한 문제라면 rtol, atol을 문제의 단위와 규모에 맞게 지정한다.

6. Broadcasting — 크기가 달라도 연산 가능한 경우

스칼라 10 + a는 모든 원소에 10을 더한다. 배열끼리도 다음 규칙을 만족하면 크기를 맞추어 원소별 연산을 할 수 있다.

  1. shape를 오른쪽 축부터 비교한다.
  2. 각 축의 길이가 같거나, 둘 중 하나가 1이면 호환된다.
  3. 없는 왼쪽 축은 길이 1인 것처럼 생각한다.
import numpy as np

column = np.array([[10], [20], [30]])  # (3, 1)
row = np.array([[1, 2, 3, 4]])         # (1, 4)
result = column + row
print(result.shape)  # (3, 4)
print(result)
# [[11 12 13 14]
#  [21 22 23 24]
#  [31 32 33 34]]

3행 1열 배열과 1행 4열 배열을 더해 3행 4열 결과를 만드는 broadcasting 도식

그림은 값을 어떻게 재사용하는지 보여 주는 개념도다. 같은 값을 실제로 큰 배열에 복제하는 np.tile()을 호출할 필요는 없다.

shape 조합결과
(3, 4) + (4,)(3, 4)
(3, 4) + (3, 1)(3, 4)
(3, 4) + (3,)오류: 오른쪽 4와 3이 다름
(3, 1) + (1, 4)(3, 4)

행별 값 3개를 3행 4열 배열에 더하고 싶으면 (3,)를 (3, 1)로 바꾸어야 한다. rows[:, None] 또는 rows.reshape(-1, 1)을 쓸 수 있다. None은 이 인덱싱 위치에서 새 축을 추가한다.

import numpy as np

A = np.zeros((3, 4))
rows = np.array([10, 20, 30])
print((A + rows[:, None]).shape)  # (3, 4)
try:
    A + rows
except ValueError:
    print("incompatible shapes")

7. 인덱싱과 슬라이싱 — 행 먼저, 열 나중

7.1 1차원 배열

import numpy as np

a = np.arange(6)
print(a[0], a[-1]) # 0 5
print(a[2:5])      # [2 3 4]
print(a[::2])      # [0 2 4]
print(a[::-1])     # [5 4 3 2 1 0]
print(a[99:])      # []: 슬라이스는 범위를 넘겨도 잘릴 수 있음
try:
    print(a[99])
except IndexError:
    print("index out of bounds")

[start:stop:step]에서 start는 포함, stop은 제외한다. a[2:5]는 인덱스 2·3·4다. a[-1]은 마지막 원소다. 단일 인덱스 초과와 슬라이스 초과의 동작은 다르다.

7.2 2차원 배열과 차원 유지

import numpy as np

A = np.arange(12).reshape(3, 4)
print(A[1, 2])           # 6
print(A[1:3, 0:2])
# [[4 5]
#  [8 9]]
print(A[-2:, :2].shape)  # (2, 2)
print(A[1].shape)        # (4,): 한 행을 1차원으로
print(A[1:2].shape)      # (1, 4): 행 축을 유지
print(A[:, 1].shape)    # (3,)
print(A[:, 1:2].shape)  # (3, 1)

정수 인덱스는 해당 축을 제거하고, 슬라이스는 축을 유지한다. 계산식이 맞는데 broadcasting이나 행렬 곱이 실패하면 이 차원을 확인한다.

7.3 A[rows, columns]와 연속 대괄호는 다르다

import numpy as np

A = np.arange(12).reshape(3, 4)
print(A[1:3, 0:2].shape)  # (2, 2)
print(A[1:3][0:2].shape)  # (2, 4)

A[1:3][0:2]는 먼저 행 두 개를 고른 다음, 그 결과에서 다시 행을 고른다. 두 번째 대괄호가 자동으로 열을 고르는 것이 아니다. 단일 원소 A[1][2]는 이 경우 A[1, 2]와 같은 값을 주지만, 2차원 영역을 자를 때는 쉼표 표기가 정확하고 읽기 쉽다.

8. View와 copy — 원본까지 바뀌는 이유

8.1 기본 슬라이스는 데이터를 공유한다

import numpy as np

a = np.arange(6)
view = a[2:5]
view[1] = -1
print(view)                    # [ 2 -1  4]
print(a)                       # [ 0  1  2 -1  4  5]
print(np.shares_memory(a, view)) # True
view[:] = 0
print(a)                       # [0 1 0 0 0 5]

view[1]은 원본의 인덱스 3과 연결된다. 서로 다른 배열 객체라도 실제 데이터 저장 영역을 공유할 수 있다. view[:] = 0은 view가 가리키는 영역 전체를 바꾸므로 원본의 그 영역도 바뀐다.

8.2 독립 결과가 필요하면 .copy()

import numpy as np

a = np.arange(6)
copied = a[2:5].copy()
copied[1] = -1
print(copied)                   # [ 2 -1  4]
print(a)                        # [0 1 2 3 4 5]
print(np.shares_memory(a, copied)) # False

같은 슬라이스를 수정했을 때 view는 원본도 바뀌고 copy는 원본이 유지되는 비교

코드관계
b = a같은 배열 객체를 가리키는 다른 이름
b = a[2:5]보통 다른 배열 객체지만 데이터를 공유하는 view
b = a[2:5].copy()별도 데이터의 독립 배열
b = a[[2, 3, 4]]정수 배열을 이용한 advanced indexing 결과는 copy
b = a[a > 2]Boolean indexing으로 읽어 온 결과는 copy

주의: “NumPy의 모든 인덱싱은 view”는 틀리다. 기본 슬라이스와 정수 배열·Boolean 마스크 인덱싱을 구분한다.

8.3 view의 내용 변경과 이름 재대입

import numpy as np

a = np.arange(6)
view = a[2:5]
view = np.zeros(3, dtype=int)
print(a)  # [0 1 2 3 4 5]: view라는 이름의 대상만 바꿈

view = a[2:5]
view[:] = 0
print(a)  # [0 1 0 0 0 5]: 연결된 데이터 내용을 바꿈

이전 글의 values = ...와 values[0] = ... 차이가 여기에도 적용된다. 원본 변경 여부를 확실히 검사할 때는 b is a만 보지 말고 np.shares_memory(a, b)도 확인한다.

9. reshape, .T, 행·열 벡터

9.1 원소 개수는 보존된다

import numpy as np

a = np.arange(12)
M = a.reshape(3, 4)
print(M.shape)           # (3, 4)
print(a.reshape(3, -1).shape) # (3, 4)
print(M.T.shape)         # (4, 3)
print(M.T)
# [[ 0  4  8]
#  [ 1  5  9]
#  [ 2  6 10]
#  [ 3  7 11]]
try:
    a.reshape(5, 3)
except ValueError:
    print("element count does not match")

12개를 3×4로 바꿀 수 있지만 5×3으로 바꿀 수는 없다. -1은 나머지 축 크기에서 해당 길이를 추론하라는 뜻이며 한 축에만 사용할 수 있다. 기본 reshape는 C 순서, 즉 마지막 축이 먼저 변하는 순서로 값을 배치한다.

reshape는 가능하면 view를 만들지만 데이터 배치에 따라 복사가 필요할 수 있다. 항상 독립 복사이거나 항상 view라고 가정하지 않는다. .T도 일반적으로 원본 데이터를 공유하는 전치 view다. 독립 결과가 필요하면 마지막에 .copy()를 붙인다.

9.2 1차원 .T는 열 벡터를 만들지 않는다

import numpy as np

v = np.array([1, 2, 3])
print(v.T.shape)          # (3,): 그대로 1차원
print(v.reshape(1, -1).shape) # (1, 3)
print(v.reshape(-1, 1).shape) # (3, 1)
print(v[:, None].shape)  # (3, 1)
print(v[None, :].shape)  # (1, 3)

모양을 한 줄로 펴고 싶으면 ravel()이나 flatten()도 볼 수 있다. ravel()은 가능하면 공유된 view를, flatten()은 복사본을 반환한다. 필요하다면 reshape(-1)로도 한 축으로 바꿀 수 있지만 공유 여부를 따로 확인한다.

10. 합·평균·분산·표준편차와 axis

10.1 전체 통계와 ddof

import numpy as np

a = np.array([1., 2., 3., 4.])
print(a.sum())            # 10.0
print(a.mean())           # 2.5
print(a.var())            # 1.25
print(round(float(a.std()), 6))        # 1.118034
print(round(float(a.std(ddof=1)), 6))  # 1.290994

a.mean()과 np.mean(a)처럼 메서드와 모듈 함수 형태를 모두 사용할 수 있는 경우가 많다. 평균은 합/N, 분산은 평균에서 떨어진 거리의 제곱을 합해 나눈 값, 표준편차는 분산의 제곱근이다.

NumPy의 분산·표준편차는 기본 ddof=0이므로 분모가 N이다. ddof=1이면 N−1을 사용한다. ddof는 단순히 “degrees of freedom 자체”가 아니라 분모에서 빼는 보정값으로 읽으면 계산을 기억하기 쉽다. N개 원소에 ddof >= N을 주면 유효한 분산 계산이 되지 않으므로 원소 수를 확인한다.

예상문제에서 default NumPy definition을 요구하면 .std()를 그대로 쓴다. 무조건 표본 표준편차라며 ddof=1을 추가하지 않는다.

10.2 axis는 어느 방향으로 더할지 고른다

A.shape == (3, 4)는 0번 축의 길이가 3(행 3개), 1번 축의 길이가 4(열 4개)라는 뜻이다. sum(axis=...)는 그 축의 인덱스를 바꿔 가며 숫자들을 더한다. 기본값인 keepdims=False에서는 더한 축이 결과의 shape에서 사라진다.

같은 열을 세로로 더하는 axis=0과 같은 행을 가로로 더하는 axis=1의 색상별 계산 과정

axis=0: 열을 하나 고정하고 행 번호를 0 → 1 → 2로 바꿔 가며 더한다. 예를 들어 첫 번째 열은 A[0, 0] + A[1, 0] + A[2, 0], 즉 0 + 4 + 8 = 12다. 모든 열에 같은 계산을 하면 다음과 같다.

고정한 열세로로 더하는 값결과
열 00 + 4 + 812
열 11 + 5 + 915
열 22 + 6 + 1018
열 33 + 7 + 1121

따라서 A.sum(axis=0)은 [12, 15, 18, 21]이다. 열마다 값이 하나 남아 총 4개이므로 결과의 shape는 (4,)다.

axis=1: 행을 하나 고정하고 열 번호를 0 → 1 → 2 → 3으로 바꿔 가며 더한다. 예를 들어 첫 번째 행은 A[0, 0] + A[0, 1] + A[0, 2] + A[0, 3], 즉 0 + 1 + 2 + 3 = 6이다.

고정한 행가로로 더하는 값결과
행 00 + 1 + 2 + 36
행 14 + 5 + 6 + 722
행 28 + 9 + 10 + 1138

따라서 A.sum(axis=1)은 [6, 22, 38]이다. 행마다 값이 하나 남아 총 3개이므로 결과의 shape는 (3,)다.

2차원 배열에서 기억할 것: axis=0 → 세로로 더함 → 열별 합. axis=1 → 가로로 더함 → 행별 합. axis를 생략하면 모든 원소를 더해 66 하나가 나온다. “0은 행 축”이라는 말은 행 번호를 바꿔 가며 계산한다는 뜻이지, 각 행의 합을 구한다는 뜻이 아니다.

import numpy as np

A = np.arange(12).reshape(3, 4)
print(A.sum())          # 66
print(A.sum(axis=0))    # [12 15 18 21]: 행 축을 줄임, 열별 합
print(A.sum(axis=1))    # [ 6 22 38]: 열 축을 줄임, 행별 합
print(A.mean(axis=1))   # [1.5 5.5 9.5]
print(A.mean(axis=1, keepdims=True).shape) # (3, 1)

axis=0을 무조건 “행별 결과”라고 외우면 헷갈린다. (3, 4)에서 0번 축인 길이 3의 행 축을 줄이면 길이 4의 열별 결과가 남는다. axis=1은 열 축을 줄여 길이 3의 행별 결과를 만든다.

keepdims=True는 줄인 축을 길이 1로 유지한다. 행별 평균을 원래 배열에서 뺄 때 (3, 1)로 남겨 두면 broadcasting과 잘 맞는다.

import numpy as np

A = np.array([[1., 2., 3.], [10., 20., 30.]])
centered = A - A.mean(axis=1, keepdims=True)
print(centered)
# [[ -1.   0.   1.]
#  [-10.   0.  10.]]
print(np.allclose(centered.mean(axis=1), 0))  # True

11. max와 argmax — 값, 인덱스, x 좌표는 다르다

import numpy as np

x = np.array([0., 0.5, 1., 1.5])
y = np.array([2., 8., 3., 1.])
index = np.argmax(y)
print(y.max())      # 8.0: 최댓값
print(int(index))   # 1: 인덱스
print(x[index])     # 0.5: 그때의 x 좌표

np.argmax(y)가 23이라고 해서 최대가 x=23에서 발생했다는 뜻은 아니다. 500개 점의 인덱스 23에 해당하는 x를 다시 x[23]으로 읽어야 한다. 같은 최대가 여러 번 있으면 1차원 argmax는 첫 위치를 반환한다. 최솟값·최소 위치는 min, argmin이다.

2차원 배열에서 axis를 생략한 argmax는 펼친 배열 기준 인덱스를 반환한다.

import numpy as np

A = np.array([[1, 9, 3], [4, 5, 6]])
flat_index = np.argmax(A)
row, col = np.unravel_index(flat_index, A.shape)
print(int(flat_index))           # 1
print(int(row), int(col))        # 0 1
print(A[row, col])               # 9
print(A.argmax(axis=1))          # [1 2]: 각 행의 최대가 있는 열

빈 배열에는 일반적인 max, argmax를 사용할 수 없다. 필터링 뒤 원소가 남았는지 selected.size > 0을 확인하고 호출한다.

12. Boolean 마스크와 조건 계산

import numpy as np

a = np.array([-2., -1., 0., 1., 2.])
mask = (a > 0) & (a < 2)
print(mask)           # [False False False  True False]
print(a[mask])        # [1.]
print(np.where(a >= 0, a, 0))  # [0. 0. 0. 1. 2.]
print(np.any(a < 0))  # True
print(np.all(a >= 0)) # False

배열의 조건을 결합할 때는 and, or 대신 &, |를 쓰고 각 비교식을 괄호로 묶는다. Boolean 배열을 뒤집을 때는 ~mask를 사용한다. Markdown 표 안에서 연산자가 헷갈리면 이 문장의 코드를 참고한다.

if a:처럼 여러 원소의 배열을 하나의 참/거짓으로 읽으려 하면 ambiguous truth value 오류가 난다. “하나라도 참인가”는 np.any, “모두 참인가”는 np.all, “비어 있는가”는 a.size == 0으로 의도를 분명히 한다.

import numpy as np

a = np.array([1, 2, 3])
b = a[a > 1]
b[0] = 99
print(a)      # [1 2 3]: 선택해 읽은 결과는 copy
a[a > 1] = 0
print(a)      # [1 0 0]: 원본에 대한 인덱싱 대입은 원본 수정

np.where(condition, a, b)는 조건에 따라 원소별 값을 고른다. Python의 한 줄 조건식처럼 선택되지 않은 쪽의 계산이 언제나 생략되는 것은 아니다. np.where(x != 0, 1/x, 0)도 먼저 1/x를 계산할 수 있으므로 0 나눗셈 경고를 완전히 막는 도구로 생각하지 않는다.

13. 원소별 곱, 행렬 곱과 선형대수

13.1 *와 @는 계산 자체가 다르다

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]]
print(np.array_equal(A @ B, np.dot(A, B)))  # True: 이 2차원 경우

행렬 곱 (m, n) @ (n, p)는 (m, p)다. 안쪽 길이 n이 같아야 한다. 반면 원소별 곱은 같은 shape 또는 broadcasting 규칙을 만족해야 한다. 행렬 곱이 필요한 곳에 *를 넣으면 오류 없이 다른 계산을 할 수도 있어 더 주의해야 한다.

1차원끼리 @를 쓰면 내적이다. np.dot과 @는 일반적인 2차원 행렬 곱에서는 같지만, 더 높은 차원에서는 동작 규칙이 달라질 수 있다. 이 글의 문제에서는 @를 사용하면 행렬 곱 의도가 분명하다.

13.2 np.linalg.solve(A, b)로 Ax=bAx=b 풀기

import numpy as np

A = np.array([[3., 1.], [1., 2.]])
b = np.array([9., 8.])
solution = np.linalg.solve(A, b)
print(solution)          # [2. 3.]
print(A @ solution)      # [9. 8.]
print(np.allclose(A @ solution, b))  # True

여기서 solution은 계수 행렬 A와 우변 b로부터 계산한 해다. 정답 [2, 3]을 코드에 그대로 대입하는 것은 방정식을 푼 것이 아니다. 함수 그래프의 x와 혼동하지 않게 변수 이름을 solution으로 구분했다.

solve는 이 문제처럼 정방이고 full-rank인 계수 행렬을 전제로 한다. 특이행렬이면 np.linalg.LinAlgError가 발생할 수 있다. 직사각형 행렬이나 최소제곱 문제는 np.linalg.lstsq처럼 별도의 문제 설정이므로 solve를 무조건 적용하지 않는다.

13.3 역행렬과 행렬식

import numpy as np

A = np.array([[1., 2.], [2., 1.]])
inverse = np.linalg.inv(A)
print(np.round(inverse, 4))
# [[-0.3333  0.6667]
#  [ 0.6667 -0.3333]]
print(round(float(np.linalg.det(A)), 6))  # -3.0
print(np.allclose(A @ inverse, np.eye(2))) # True

A ** -1은 원소별 역수이지 행렬의 역행렬이 아니다. inv의 결과를 확인할 때는 A @ inverse가 단위행렬과 가까운지 검사한다. 단순히 Ax=bAx=b를 풀 목적이라면 역행렬을 따로 계산하기보다 solve를 쓴다. 행렬식이 매우 작은지의 해석에는 스케일과 수치 오차도 영향을 주므로 작은 실수 하나만 보고 일반적인 수치 안정성을 단정하지 않는다.

14. 난수 — 평균·표준편차·개수·seed

14.1 자주 쓰는 함수 비교

함수의미모양 지정
np.random.rand(2, 3)0 이상 1 미만 균등 난수축 길이를 위치 인자로
np.random.randn(2, 3)평균 0, 표준편차 1 정규 난수축 길이를 위치 인자로
np.random.normal(mu, sigma, size=(2, 3))평균 mu, 표준편차 sigma 정규 난수size
np.random.randint(low, high, size=5)low 이상 high 미만 정수 난수size
import numpy as np

np.random.seed(607)
print(np.random.rand(2, 3).shape)                 # (2, 3)
print(np.random.randn(5).shape)                   # (5,)
print(np.random.normal(10, 2, size=100).shape)    # (100,)
print(np.random.randint(0, 4, size=5).shape)      # (5,)

randn의 n은 normal을 떠올리면 된다. randn의 인자는 평균·표준편차가 아니라 shape다. 평균 mu, 표준편차 sigma를 만들 때 mu + sigma * np.random.randn(N) 또는 np.random.normal(mu, sigma, N)을 사용한다.

14.2 분산이 1/2라면 sigma는 1/np.sqrt(2)

분산은 표준편차의 제곱이다. 정규 난수의 분산이 1/2이면 표준편차는 sqrt1/2=1/sqrt2sqrt{1/2}=1/sqrt{2}다. normal(0, 0.5, N)을 쓰면 표준편차가 0.5, 분산이 0.25인 다른 난수를 만든다.

표본 평균·표준편차가 지정한 분포의 모수와 정확히 같아야 하는 것은 아니다. 유한 개의 난수이므로 차이가 난다. 분포 모양과 적절한 허용 오차로 확인한다.

14.3 seed는 한 번 설정하고 연속으로 뽑는다

import numpy as np

np.random.seed(607)
real = np.random.normal(0, 1/np.sqrt(2), 10_000)
imag = np.random.normal(0, 1/np.sqrt(2), 10_000)
print(real.shape, imag.shape)        # (10000,) (10000,)
print(np.array_equal(real, imag))    # False

두 배열 사이에 같은 seed를 다시 설정하면 같은 난수열이 나와 독립 성분이라는 의도가 깨질 수 있다. seed 고정은 예제를 재현하기 위한 것이며 실수부와 허수부를 같게 만들라는 뜻이 아니다. 10_000의 밑줄은 숫자를 읽기 쉽게 구분하는 Python 문법이다. 값은 10000과 같다.

새 코드를 자유롭게 작성할 때는 np.random.default_rng(seed)로 Generator를 만들어 쓰는 방식도 있다. 다만 예상문제는 np.random.randn() 또는 np.random.normal()을 명시하므로 전체 풀이에서는 요구한 함수로 구현한다.

import numpy as np

rng = np.random.default_rng(607)
sample = rng.normal(0, 1, size=5)
integers = rng.integers(0, 4, size=5)
print(sample.shape, integers.shape)  # (5,) (5,)

15. 복소수와 Rayleigh 분포 — real + 1j*imag

import numpy as np

real = np.array([3., 0.])
imag = np.array([4., -2.])
z = real + 1j * imag
print(z)              # [3.+4.j 0.-2.j]
print(np.abs(z))      # [5. 2.]
print(np.allclose(np.abs(z), np.sqrt(real**2 + imag**2))) # True

Python은 허수 단위로 1j를 사용한다. j라는 이름을 별도로 선언할 필요가 없다. 복소수의 크기는 sqrtreal2+imag2sqrt{\mathrm{real}^2+\mathrm{imag}^2}이므로 음수가 아니다. np.abs(real + imag)는 두 실수의 합에 절댓값을 취한 것이어서 다른 계산이다.

서로 독립인 평균 0, 같은 표준편차 sigma의 Gaussian 성분 X와 Y에 대해 R=∣X+jY∣R=|X+jY|는 Rayleigh 분포를 따른다. 예상문제에서는 sigma가 1/sqrt21/sqrt{2}다. 크기는 주로 작은 양수 쪽에서 많고 오른쪽으로 꼬리가 생긴다. 위의 [3, 4]는 크기 계산을 설명한 결정적 예제일 뿐 Gaussian 표본은 아니다.

이 경우 E[R2]=2σ2=1E[R^2]=2\sigma^2=1이므로 실제 난수의 np.mean(magnitude**2)가 1 근처인지 확인해 볼 수 있다. 난수 생성의 건전성 확인이지, 유한 표본이 분포를 따른다는 수학적 증명을 대신하는 것은 아니다.

16. Matplotlib — Figure와 Axes부터 구분하기

16.1 그래프 전체와 한 칸의 축

Figure는 전체 그림이고, Axes는 실제 데이터를 그리는 한 영역이다. 아래 예제는 Figure 하나에 Axes 하나를 만든다.

import numpy as np
import matplotlib.pyplot as plt

x = np.linspace(0, 2*np.pi, 100)
fig, ax = plt.subplots()
ax.plot(x, np.sin(x), color="royalblue", label="sin(x)")
ax.plot(x, np.cos(x), color="darkorange", linestyle="--", label="cos(x)")
ax.set_title("Sinusoidal curves")
ax.set_xlabel("x (radians)")
ax.set_ylabel("Function value")
ax.grid(True, alpha=0.3)
ax.legend()
fig.tight_layout()
plt.show()
plt.close(fig)

label=을 지정하고 legend()를 호출해야 범례가 나타난다. 색뿐 아니라 선 종류도 다르게 하면 구별하기 쉽다. 형식 문자열 'r--'는 빨간 점선, 'go-'는 초록 원형 마커와 실선이다. color, linestyle, marker 키워드로 나누어 써도 된다.

pyplot 방식Axes 방식
plt.plot(x, y)ax.plot(x, y)
plt.title("...")ax.set_title("...")
plt.xlabel("...")ax.set_xlabel("...")
plt.ylabel("...")ax.set_ylabel("...")
plt.grid(True)ax.grid(True)
plt.legend()ax.legend()

두 방식 모두 쓸 수 있지만 subplot이 여러 개면 axes[0], axes[1]처럼 어느 축을 수정하는지 직접 지정하는 방식이 실수를 줄인다.

16.2 Scatter와 bar

import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(607)
x = rng.normal(size=100)
y = 0.5*x + rng.normal(scale=0.3, size=100)
fig, ax = plt.subplots()
ax.scatter(x, y, s=18, alpha=0.6)
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_title("Scatter plot")
ax.grid(True, alpha=0.3)
plt.show()
plt.close(fig)

scatter는 두 측정량의 관계를 점으로 보여 준다. plot처럼 점을 순서대로 선으로 잇는 것과 목적이 다르다.

import matplotlib.pyplot as plt

fig, ax = plt.subplots()
ax.bar([0, 1, 2], [12, 8, 5], width=0.6, edgecolor="black")
ax.set_xticks([0, 1, 2], ["A", "B", "C"])
ax.set_ylabel("Count")
ax.set_title("Category counts")
fig.tight_layout()
plt.show()
plt.close(fig)

bar는 이미 정리된 범주별 값을 비교한다. histogram은 원자료를 구간에 나누어 도수를 계산한다. bar에 난수 10,000개를 그대로 주는 것은 문제에서 요구한 분포 히스토그램과 다르다.

17. 히스토그램·subplot·파일 저장

17.1 bins는 막대 높이가 아니라 구간 설정

import numpy as np
import matplotlib.pyplot as plt

data = np.array([0.1, 0.2, 0.7, 1.2, 1.8])
fig, ax = plt.subplots()
counts, edges, patches = ax.hist(data, bins=[0, 1, 2], density=False)
print(counts)                    # [3. 2.]
print(edges)                     # [0. 1. 2.]
print(int(counts.sum()))         # 5
print(len(patches))              # 2
plt.close(fig)

bins=100이면 보통 데이터 범위를 100개 구간으로 나누고 각 구간의 개수를 센다. 높이 100의 막대를 만들라는 뜻이 아니다. 구간이 100개면 경계 값은 101개다. 일반적으로 구간은 왼쪽 포함·오른쪽 제외이며, 마지막 구간은 오른쪽 끝도 포함한다.

17.2 횟수·상대도수·확률밀도는 y축이 다르다

목적설정y축 의미 / 확인
횟수 counts기본 또는 density=False막대 높이의 합이 포함된 표본 수
상대도수 relative frequencyweights=np.ones(N)/N각 구간의 표본 비율, 전체 범위를 포함하면 높이 합 1
확률밀도 densitydensity=True높이×구간 폭의 합이 1

같은 데이터와 구간으로 그린 횟수·상대도수·확률밀도 히스토그램의 y축 비교

위 그림의 표본 수는 2,000개다. 세 그래프는 같은 데이터를 쓰지만 y축의 정의가 다르다. 밀도 높이 자체는 그 구간의 확률이 아니며 1보다 클 수도 있다. 확률은 해당 구간의 밀도×폭으로 해석한다. 일반적인 데이터에서 weights 방식과 density 방식은 같지 않다.

예상문제 3번은 발생 횟수를 요구한다. 따라서 density=False로 두고 weights를 추가하지 않는다. 10,000개를 전부 포함하면 counts.sum()이 10,000이어야 한다. range=로 일부 표본을 제외하면 횟수 합이 전체 N보다 작아질 수 있다.

17.3 위아래 두 개의 subplot

import numpy as np
import matplotlib.pyplot as plt

x = np.linspace(0, 1, 5)
fig, axes = plt.subplots(2, 1, figsize=(7, 6))
axes[0].plot(x, x**2)
axes[0].set_title("Square")
axes[0].set_xlabel("x")
axes[0].set_ylabel("x squared")
axes[1].hist([0.1, 0.2, 0.8, 1.2], bins=4)
axes[1].set_title("Histogram")
axes[1].set_xlabel("Value")
axes[1].set_ylabel("Count")
axes[0].grid(True)
axes[1].grid(True)
fig.tight_layout()
fig.savefig("two_panels.png", dpi=120)
plt.show()
plt.close(fig)

subplots(2, 1)은 2행 1열이다. 기본 squeeze 동작에서는 이 경우 axes가 길이 2인 1차원 배열이므로 axes[0], axes[1]을 사용한다. subplots(1, 1)의 기본 반환 ax는 단일 Axes다. 언제나 2차원 인덱싱을 쓰고 싶으면 squeeze=False를 명시하는 방법도 있다.

17.4 저장 위치와 y축 자동 범위

fig.savefig(...)는 plt.show()보다 먼저 실행한다. 창을 닫은 뒤 현재 figure가 달라지거나 사라져 빈 그림을 저장하는 실수를 줄일 수 있다. fig.tight_layout()은 제목·축 이름 간 간격을 조정한다. 최신 코드에서는 layout="constrained"를 사용하는 선택지도 있지만 전체 풀이에서는 한 방식을 일관되게 사용한다.

"simulation.png"만 지정하면 현재 작업 폴더에 저장된다. 전체 풀이에서는 Path(__file__).resolve().parent를 사용하여 .py 파일 옆에 저장한다. 노트북에는 __file__이 없을 수 있으므로 그때는 Path.cwd()처럼 저장 폴더를 직접 정한다.

그래프의 y축 끝이 0.8 대신 0.81처럼 보이는 것은 데이터·여백·자동 축 범위에 따른 결과일 수 있다. 함수 자체가 틀렸다는 증거는 아니다. 정확한 범위를 요구하면 ax.set_ylim(bottom, top)을 사용하되 실제 데이터가 잘리지 않는지 확인한다. 난수 히스토그램은 seed·표본·bins가 바뀌면 막대 높이도 바뀐다.

18. 예상문제 3번 — 요구사항을 계산 단계로 나누기

단계해야 할 일사용할 도구
P3.10–10 양 끝 포함 500점, 두 감쇠 함수, 통계·최대 위치linspace, sin, cos, exp, mean, std, max, argmax
P3.2f의 앞 12개를 3×4, 전치, 마지막 두 행·처음 두 열, view/copy 수정[:12], reshape, .T, [-2:, :2], .copy()
P3.3주어진 2×2 연립방정식 풀고 검증linalg.solve, @, allclose
P3.4실수·허수 Gaussian 배열 각 10,000개, 표준편차 1/21/\sqrt{2}, 크기random.normal, 1j, np.abs
P3.5위 함수 두 곡선, 아래 100구간 횟수 히스토그램, 축·범례·격자·저장subplots(2,1), plot, hist, savefig

두 함수는 다음과 같다.

f(x)=sin⁡(3x)e−x/2,g(x)=cos⁡(2x)e−x/2f(x)=\sin(3x)e^{-x/2},\qquad g(x)=\cos(2x)e^{-x/2}

부호와 괄호를 확인한다. np.exp(-x/2)와 np.exp(-x)/2는 다르다. sin(3*x)의 3과 cos(2*x)의 2도 구분한다.

구현 순서: 좌표·함수 배열 생성 → 원본 통계 계산 → 복사한 행렬에서 슬라이싱 실험 → 연립방정식 → 복소 난수 → 그래프와 저장 → 모양·계산 검증.

19. 전체 풀이 — numpy_simulation.py

아래 전체 블록을 하나의 파일로 저장해 실행한다. create_curves, make_slice_examples처럼 단계별 함수로 나누었지만 원소별 함수 계산과 난수 생성은 배열 식으로 처리한다. Python for로 x의 각 원소를 계산하거나 복소 난수를 하나씩 만들지 않는다.

"""Lecture 06 / Practice Problem 3: vectorized NumPy and Matplotlib."""
from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt


def create_curves():
    x = np.linspace(0, 10, 500)
    f = np.sin(3 * x) * np.exp(-x / 2)
    g = np.cos(2 * x) * np.exp(-x / 2)
    return x, f, g


def make_slice_examples(f):
    # Keep the original f intact for statistics and plotting.
    matrix = f[:12].reshape(3, 4).copy()
    initial = matrix.copy()
    transpose = matrix.T.copy()

    view = matrix[-2:, :2]
    view[0, 0] = -1.0
    # A basic slice shares storage: matrix[1, 0] also becomes -1.0.

    independent_matrix = f[:12].reshape(3, 4).copy()
    copied_slice = independent_matrix[-2:, :2].copy()
    copied_slice[0, 0] = -999.0
    # The copied slice owns separate data; independent_matrix is unchanged.
    return initial, transpose, matrix, view, independent_matrix, copied_slice


def solve_system():
    A = np.array([[3.0, 1.0], [1.0, 2.0]])
    b = np.array([9.0, 8.0])
    solution = np.linalg.solve(A, b)
    return A, b, solution


def generate_rayleigh(sample_count=10_000, seed=607):
    if sample_count <= 0:
        raise ValueError("sample_count must be positive")
    # The practice problem explicitly requests np.random.normal or randn.
    np.random.seed(seed)
    sigma = 1 / np.sqrt(2)
    real = np.random.normal(0.0, sigma, sample_count)
    imag = np.random.normal(0.0, sigma, sample_count)
    z = real + 1j * imag
    magnitude = np.abs(z)
    return real, imag, z, magnitude


def make_figure(x, f, g, magnitude):
    fig, axes = plt.subplots(2, 1, figsize=(10, 8))
    axes[0].plot(x, f, color="royalblue", label=r"$f(x)=\sin(3x)e^{-x/2}$")
    axes[0].plot(x, g, color="darkorange", linestyle="--",
                 label=r"$g(x)=\cos(2x)e^{-x/2}$")
    axes[0].set_title("Damped sinusoidal functions")
    axes[0].set_xlabel("x")
    axes[0].set_ylabel("Function value")
    axes[0].grid(True, alpha=0.3)
    axes[0].legend()

    counts, edges, _ = axes[1].hist(magnitude, bins=100, density=False,
                                   color="steelblue", edgecolor="white",
                                   linewidth=0.3)
    axes[1].set_title("Rayleigh-distributed magnitudes")
    axes[1].set_xlabel("Magnitude")
    axes[1].set_ylabel("Number of occurrences")
    axes[1].grid(True, alpha=0.3)
    fig.tight_layout()
    return fig, axes, counts, edges


def main():
    np.set_printoptions(precision=4, suppress=True)
    x, f, g = create_curves()
    peak_index = np.argmax(f)
    print("shape:", f.shape)
    print(f"mean: {f.mean():.6f}")
    print(f"std (ddof=0): {f.std():.6f}")
    print(f"maximum: {f.max():.6f}")
    print("maximum index:", int(peak_index))
    print(f"x at maximum: {x[peak_index]:.6f}")

    initial, transpose, changed, view, independent, copied = make_slice_examples(f)
    print("matrix shape:", initial.shape)
    print(initial)
    print("transpose shape:", transpose.shape)
    print(transpose)
    print("view shape:", view.shape)
    print("matrix[1, 0] after view edit:", changed[1, 0])
    print("view shares memory:", np.shares_memory(changed, view))
    print("copy shares memory:", np.shares_memory(independent, copied))
    print("independent matrix unchanged:", np.array_equal(independent, initial))

    A, b, solution = solve_system()
    print("solution:", solution)
    print("A @ solution:", A @ solution)
    print("system verified:", np.allclose(A @ solution, b))

    real, imag, z, magnitude = generate_rayleigh()
    assert x.shape == f.shape == g.shape == (500,)
    assert real.shape == imag.shape == z.shape == magnitude.shape == (10_000,)
    assert np.all(magnitude >= 0)
    assert np.allclose(magnitude, np.sqrt(real**2 + imag**2))

    fig, axes, counts, edges = make_figure(x, f, g, magnitude)
    assert len(counts) == 100
    assert len(edges) == 101
    assert np.isclose(counts.sum(), magnitude.size)
    print("random array shape:", magnitude.shape)
    print("histogram bins:", len(counts))
    print("histogram total count:", int(counts.sum()))

    output = Path(__file__).resolve().parent / "simulation.png"
    fig.savefig(output, dpi=160, bbox_inches="tight")
    print("Saved:", output.name)
    plt.show()
    plt.close(fig)


if __name__ == "__main__":
    main()

실행 명령이다.

python numpy_simulation.py

출력 예시는 다음과 같다. seed는 607이며 행렬 출력 정밀도는 네 자리로 설정했다.

shape: (500,)
mean: 0.032353
std (ddof=0): 0.217951
maximum: 0.780170
maximum index: 23
x at maximum: 0.460922
matrix shape: (3, 4)
[[0.     0.0595 0.1176 0.1741]
 [0.2288 0.2816 0.3324 0.3809]
 [0.427  0.4706 0.5117 0.5501]]
transpose shape: (4, 3)
[[0.     0.2288 0.427 ]
 [0.0595 0.2816 0.4706]
 [0.1176 0.3324 0.5117]
 [0.1741 0.3809 0.5501]]
view shape: (2, 2)
matrix[1, 0] after view edit: -1.0
view shares memory: True
copy shares memory: False
independent matrix unchanged: True
solution: [2. 3.]
A @ solution: [9. 8.]
system verified: True
random array shape: (10000,)
histogram bins: 100
histogram total count: 10000
Saved: simulation.png

실제 전체 풀이로 계산한 두 감쇠 곡선과 100구간 Rayleigh 크기 횟수 히스토그램

이 그림은 예제 코드로 계산한 실제 결과다. 원본 문제의 그림을 외워 재현하거나 그래프 수치를 하드코딩한 것이 아니다. y축 아래 그래프가 확률밀도가 아닌 Number of occurrences인지 확인한다.

20. 전체 풀이에서 중요한 다섯 가지 결정

20.1 통계는 500개 전체 f에서 계산한다

f.mean(), f.std(), f.max()는 f 배열 전체를 대상으로 한다. 앞 12개를 뽑은 행렬에서 통계를 구하면 다른 문제를 푼다. peak_index=np.argmax(f) 다음에는 x[peak_index]를 사용하여 원래 x 좌표를 찾는다. 이 예제에서는 최대 인덱스가 23이고 x는 약 0.460922다.

이는 500개 격자점 중의 최대다. 연속 함수 전체의 정확한 최대 위치를 미분하여 구하는 문제와는 다르다. 표본 수가 바뀌면 최대 인덱스·좌표가 조금 달라질 수 있다.

20.2 .reshape(3,4).copy()로 f와 실험 행렬을 분리한다

단순히 matrix=f[:12].reshape(3,4)라고 하면 이 연속 데이터에서는 원래 f와 메모리를 공유한다. view 실험으로 값을 -1로 바꾸면 f도 바뀌어 나중의 함수 그래프에 이상한 꺾임이 생길 수 있다.

그래서 실험용 행렬을 만들 때 마지막에 .copy()를 붙였다. 그 행렬의 부분 슬라이스는 다시 view여서 행렬을 수정하지만, 원래 f 곡선까지 수정하지는 않는다. 문제에서 보여야 하는 view/copy 차이와 그래프 데이터 보존을 둘 다 만족한다.

20.3 수정 전 행렬과 전치는 출력용 snapshot을 둔다

initial=matrix.copy(), transpose=matrix.T.copy()는 수정 전 상태를 남긴다. 단순히 transpose=matrix.T로만 두면 나중에 matrix를 수정할 때 transpose가 보여 주는 값도 바뀔 수 있다. “전치 값을 처음 상태로 출력하려고 보관했다”는 목적에 맞추어 복사했다.

view=matrix[-2:, :2]의 [0,0]은 matrix의 [1,0]에 해당한다. view에 -1을 대입한 뒤 changed[1,0]이 -1인지 확인한다. copy 실험은 원래 f에서 별도로 만든 independent_matrix에서 시작하여 앞 실험의 수정이 섞이지 않게 했다.

20.4 복소수의 크기는 배열 한 줄로 계산한다

real과 imag는 각각 shape (10000,)이고, z=real+1j*imag도 같은 shape다. np.abs(z)는 각 복소수의 크기를 한 번에 계산한다. np.sqrt(real**2+imag**2)와 가까운지 검사하면 실수부·허수부 결합 실수를 잡을 수 있다.

20.5 히스토그램은 두 가지 길이를 확인한다

막대 개수는 len(counts)=100, 경계 수는 len(edges)=101이다. weights와 density 정규화를 사용하지 않았으며 전체 데이터를 포함하므로 counts.sum()은 10,000이다. 이 검사는 표본 수 누락·잘못된 히스토그램 모드를 발견하는 데 도움이 된다.

21. 검증 — 그림이 비슷한지만 보지 않기

아래 파일은 전체 풀이와 같은 폴더에 test_numpy_simulation.py로 저장한다. 그림 테스트는 창을 띄우지 않는 Agg backend를 사용하고 저장된 파일도 확인한다.

from pathlib import Path
import tempfile
import unittest
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from numpy_simulation import (create_curves, make_slice_examples, solve_system,
                              generate_rayleigh, make_figure)


class SimulationTests(unittest.TestCase):
    def test_grid_endpoints_and_spacing(self):
        x, f, g = create_curves()
        self.assertEqual(x.shape, (500,))
        self.assertEqual(f.shape, x.shape)
        self.assertEqual(g.shape, x.shape)
        self.assertEqual(x[0], 0)
        self.assertEqual(x[-1], 10)
        np.testing.assert_allclose(np.diff(x), 10 / 499)

    def test_functions_and_maximum_coordinate(self):
        x, f, g = create_curves()
        np.testing.assert_allclose(f, np.sin(3 * x) * np.exp(-x / 2))
        np.testing.assert_allclose(g, np.cos(2 * x) * np.exp(-x / 2))
        self.assertEqual(f[np.argmax(f)], f.max())
        self.assertGreater(x[np.argmax(f)], 0)
        self.assertTrue(np.isfinite(f.std()))

    def test_view_copy_and_original_curve(self):
        _, f, _ = create_curves()
        before = f.copy()
        initial, transpose, matrix, view, independent, copied = make_slice_examples(f)
        self.assertEqual(initial.shape, (3, 4))
        self.assertEqual(transpose.shape, (4, 3))
        self.assertEqual(view.shape, (2, 2))
        self.assertTrue(np.shares_memory(matrix, view))
        self.assertEqual(matrix[1, 0], -1)
        self.assertFalse(np.shares_memory(independent, copied))
        np.testing.assert_array_equal(independent, initial)
        np.testing.assert_array_equal(f, before)
        np.testing.assert_array_equal(transpose, initial.T)

    def test_linear_system(self):
        A, b, solution = solve_system()
        np.testing.assert_allclose(solution, [2, 3])
        np.testing.assert_allclose(A @ solution, b)

    def test_complex_magnitudes_and_shapes(self):
        real, imag, z, magnitude = generate_rayleigh()
        self.assertEqual(real.shape, (10_000,))
        self.assertEqual(imag.shape, real.shape)
        self.assertTrue(np.iscomplexobj(z))
        np.testing.assert_array_equal(z.real, real)
        np.testing.assert_array_equal(z.imag, imag)
        np.testing.assert_allclose(magnitude, np.sqrt(real**2 + imag**2))
        self.assertTrue(np.all(magnitude >= 0))

    def test_random_parameters_sanity(self):
        real, imag, _, magnitude = generate_rayleigh(seed=607)
        self.assertLess(abs(real.mean()), 0.03)
        self.assertLess(abs(imag.mean()), 0.03)
        self.assertAlmostEqual(real.std(), 1 / np.sqrt(2), delta=0.03)
        self.assertAlmostEqual(imag.std(), 1 / np.sqrt(2), delta=0.03)
        self.assertLess(abs(np.corrcoef(real, imag)[0, 1]), 0.05)
        self.assertAlmostEqual(np.mean(magnitude**2), 1, delta=0.05)

    def test_histogram_and_saved_figure(self):
        x, f, g = create_curves()
        *_, magnitude = generate_rayleigh()
        fig, axes, counts, edges = make_figure(x, f, g, magnitude)
        try:
            self.assertEqual(len(fig.axes), 2)
            self.assertEqual(len(axes[0].lines), 2)
            self.assertEqual(counts.shape, (100,))
            self.assertEqual(edges.shape, (101,))
            self.assertEqual(counts.sum(), 10_000)
            with tempfile.TemporaryDirectory(dir=Path(__file__).parent) as folder:
                output = Path(folder) / "test.png"
                fig.savefig(output)
                self.assertGreater(output.stat().st_size, 0)
        finally:
            plt.close(fig)

    def test_invalid_sample_count(self):
        with self.assertRaises(ValueError):
            generate_rayleigh(0)
        with self.assertRaises(ValueError):
            generate_rayleigh(-1)


if __name__ == "__main__":
    unittest.main(verbosity=2)
python -m unittest -v test_numpy_simulation

자동 테스트 8개를 통과했다. 테스트 범위는 다음과 같다.

  • 점 500개의 양 끝점·간격·shape와 두 함수식
  • 최댓값과 인덱스 연결
  • view의 메모리 공유, copy의 독립성, 원래 f 보존, 전치
  • 연립방정식 해와 행렬 곱 재검증
  • 복소수 실수부·허수부·크기·shape
  • 고정 seed 표본의 평균·표준편차·상관·크기 제곱 평균 sanity check
  • subplot 두 개·곡선 두 개·100개 bins·10,000개 횟수·PNG 저장
  • 잘못된 표본 개수 거부

난수 통계 테스트의 허용 오차는 이 고정 seed 예제를 검사하기 위한 것이다. 다른 seed에서도 표본 모멘트가 항상 정확히 같은 숫자가 된다고 해석하지 않는다. 통계·선형대수의 실수 결과에는 적절한 근사 비교를 사용하고, shape·길이 같은 정수 조건은 정확히 검사한다.

22. 조건이 달라졌을 때 — 어떤 줄을 바꿀까

바뀐 요구판단과 수정
구간이 −5부터 5, 점 1000개np.linspace(-5, 5, 1000), 관련 shape 검사 수정
점 개수 대신 간격 0.1이 명시됨끝점 조건을 확인하고 arange 또는 계산한 num 사용
감쇠율이 달라짐지수의 괄호까지 포함해 np.exp(-alpha*x) 수정
앞 20개를 4×5로 바꿈f[:20].reshape(4,5), 곱이 20인지 확인
마지막 두 행·첫 두 열이 아니라 가운데 열선택할 인덱스 범위를 직접 써서 shape 확인
원본이 바뀌면 안 됨수정하기 전에 .copy(), shares_memory로 검사
행마다 평균을 빼기A - A.mean(axis=1, keepdims=True)
각 열 평균을 구하기A.mean(axis=0)
최대가 아니라 최소 위치argmin과 같은 인덱스의 x 사용
조건을 만족하는 원소만 사용Boolean 마스크, 빈 결과의 size 확인
원소별 곱이 아닌 행렬 곱@, 안쪽 shape 일치 검사
A와 b가 달라짐입력만 바꾸고 solve로 계산, allclose(A@solution,b) 검증
Gaussian 분산이 v로 주어짐표준편차 인자에 np.sqrt(v)
난수 표본 개수만 바뀜sample_count, shape·히스토그램 합 검사 함께 수정
상대도수를 요구함weights=np.ones(N)/N, y축 설명 수정
확률밀도를 요구함density=True, 면적 합 확인
그래프가 좌우 배치subplots(1,2)와 figsize 조정
한 그림에 세 개의 곡선같은 Axes에 plot 추가하고 label·legend 확인

문제의 구체적 조건이 바뀌었는데 기존 assert의 (500,), (10000,), bins 100을 그대로 두면 검증 코드가 새 요구와 충돌한다. 계산식·모양·검증·축 이름을 함께 수정한다.

23. 오류 메시지와 실수 해결표

증상 / 잘못된 코드원인과 해결
ModuleNotFoundError: numpy실행 Python과 설치 Python 확인, python -m pip install numpy
리스트 * 2가 길이만 늘어남NumPy 배열인지 확인
np.zeros(2,3)shape를 (2,3) 튜플로 전달
linspace(0,10,0.1)세 번째 인자는 간격이 아니라 정수 개수
기대와 다른 기본 int32 / int64dtype를 직접 지정하고 .dtype 확인
정수 배열에 1.9를 넣었더니 1실수 dtype으로 만들거나 astype(float) 결과 사용
a.shape()shape는 메서드가 아니라 속성, 괄호 제거
cannot reshape array원소 수와 새 shape의 곱 확인
.T를 했는데 (n,) 그대로1차원 배열임. reshape(-1,1)로 열 축 추가
operands could not be broadcast togethershape를 오른쪽부터 비교, 필요한 축을 길이 1로 추가
A[1:3][0:2]가 열을 안 자름A[1:3,0:2] 사용
슬라이스만 바꿨는데 그래프가 이상해짐view가 원본 f를 수정했는지 확인, 실험 배열 복사
복사했다고 생각했는데 원본이 바뀜b=a는 복사가 아님, .copy() 확인
truth value of an array is ambiguousand/or, if array 확인. 마스크에는 &/`
argmax 결과를 최댓값으로 출력인덱스임. y[index], x 좌표는 x[index]
표준편차 값이 예제와 다름ddof, 계산 대상, dtype·입력 데이터를 확인
math.sin(array) 오류배열 연산에는 np.sin 사용
A*B가 기대한 행렬 곱이 아님A@B와 shape 확인
LinAlgError: Singular matrix역행렬·유일 해의 조건을 만족하는지 확인
normal(0,0.5,N)표준편차를 줬는지, 분산을 잘못 넣었는지 확인
real과 imag가 완전히 같음두 번 같은 seed로 초기화했는지 확인
abs(real+imag)복소수 결합이 빠짐. np.abs(real+1j*imag)
히스토그램 y축이 0.2 정도상대도수·density 정규화 여부 확인
막대 높이가 원본 그림과 다름seed·bins·범위·표본이 같은지 확인, 난수 변동 고려
범례가 안 보임각 plot의 label과 해당 Axes의 legend 호출 확인
저장 이미지가 비거나 다른 그림fig.savefig를 show보다 먼저, 올바른 Figure 사용
저장 파일을 못 찾음현재 작업 폴더와 .py 파일 폴더 차이 확인
제목·축 이름이 겹침figsize·tight_layout 또는 constrained layout 확인

24. 확인 문제 — 직접 출력과 shape를 예상하기

  1. np.arange(12).reshape(3,4).size, len(...), .ndim은 각각 얼마인가?
  2. 그 배열에서 A[1]과 A[1:2]의 shape는 무엇인가?
  3. v=A[-2:,:2]에서 v[0,0]은 A의 어느 원소인가?
  4. v=A[-2:,:2].copy()였다면 대입 후 A가 바뀌는가?
  5. (3,4)와 (3,)를 바로 더할 수 있는가? 행별 3개 값을 더하려면?
  6. A.mean(axis=0)과 A.mean(axis=1)은 각각 무엇을 남기는가?
  7. x 좌표 500개에서 argmax(f)가 23이면 x=23에서 최대인가?
  8. 분산 1/2 정규 난수의 normal scale 인자는 얼마인가?
  9. 복소수 크기와 real+imag의 절댓값은 같은가?
  10. density 히스토그램의 막대 높이 합은 반드시 1인가?
  11. 100개 bins의 경계 값은 몇 개인가?
  12. f[:12].reshape(3,4)를 바꾸면 f가 바뀔 수 있는가?

정답: 1. 12, 3, 2. 2. (4,), (1,4). 3. A[1,0]. 4. 바뀌지 않는다. 5. 불가능하며 (3,1)로 바꾼다. 6. 열별 평균 4개, 행별 평균 3개. 7. 아니다. x[23]을 읽는다. 8. 1/np.sqrt(2). 9. 다르다. 크기는 sqrt(real**2+imag**2). 10. 아니다. 높이×폭의 합이 1이다. 11. 101개. 12. 이 연속 데이터에서는 공유될 수 있으므로 별도의 .copy()로 분리한다.

참고 자료

본문은 외부 자료를 옆에 두지 않아도 읽을 수 있게 작성했다. API의 세부 옵션을 추가로 확인할 때는 다음 공식 문서를 사용한다.