전문지식 함양/TIL

[프로그래머스 겨울방학 인공지능 과정] Numpy 기초와 선형대수 정리

샤프펜슬s 2022. 1. 4. 21:18

0. 주의사항 : 아래의 내용을 무조건적으로 신뢰하지 않기를 바란다. 프로그래밍의 세계는 매우 심오하고 복잡해서 지금 정리하는 내용이 완벽하다고 나조차도 신뢰하지 않기 때문이다.

 

1. 일자 : 2022년 1월 4일 14:00 ~ 18:00 (2일차)

 

2. 주제 : Numpy 기초 정리

 

3. 내용

(1) Numpy 모듈 불러오기 및 array 사용

 

Numpy모듈을 사용하기 위해서는 아래 코드를 입력하여 선언해야 한다.

import numpy as np

 Numpy를 사용해야 하는 이유는 array를 사용할 수 있기 때문이다. 나는 Python에서 사용하는 list는 Java의 ArrayList와 닮아있다고 생각한다. 물론 이들의 자세한 구조까지는 정확하게 모르지만 (1) 크기를 지정하지 않아도 list의 형성이 가능하며 (2) Python의 list의 크기는 가변적이므로 나는 위와 같은 판단을 내렸다. 물론 전문가들이 보기에는 다를 수도 있다.

 Array는 크기를 정할 수 있는 대신 연산속도가 비교적 빠른 편이다. 이것은 실제로 코드 몇 줄을 통해 확인이 가능하다.

# (1) Python에서 기본적으로 제공하는 list

L = range(1000)
%timeit [i**2 for i in L]

# (1) 결과값 : 229 µs ± 2.04 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

# (2) Numpy에서 제공하는 array

N = np.arange(1000)
%timeit N ** 2

# (2) 결과값 :1.4 µs ± 10.2 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
# Numpy를 사용했을 때 훨씬 더 많은 자료를 처리했음에도 속도는 훨씬 빠른 것을 확인할 수 있다.

향후 데이터 분석을 진행할 때 사용자는 상당한 양의 자료를 처리해야 하는 만큼, Numpy의 사용이 필수적이다.

 

Array를 만들기 위해서는 np.array()를 사용해야 한다.

# 1차원 배열
arr = np.array([1, 2, 3])
print(arr)
# 출력값 : array([1, 2, 3])

# 2차원 배열
arr_2d = np.array([[1, 2], [3, 4])
print(arr_2d)
# 출력값 : array([[1, 2]
#                [3, 4]])

추가로 Array의 형태를 확인하고 싶으면 아래의 코드를 사용하도록 하자.

# array를 생성한다.
arr = np.array([1, 2, 3])
arr_2 = np.array([[1, 2], [3, 4]])

# array의 형태를 알고 싶다면, .shape를 이용한다.
arr.shape
# 출력값 : (3, )
# 3 by 1 matrix

arr_2.shape
# 출력값 : (2, 2)
# 2 by 2 matrix

 

(2) Numpy의 연산

Numpy를 사용하려면 선수과목으로 '선형대수'를 배우면 좋다. 나는 3학년 2학기 때 선수과목으로 선형대수를 배운 바 있으므로 아는 지식 선에서 Numpy 코드를 설명하고자 한다.

 

(2.1) Vector와 Scalar 사이의 연산

Vector는 행이나 열이 하나밖에 없는 행렬을 가리킨다. 일반적으로 숫자를 콤마(,)로 분리하고 대괄호([])로 감싸서 표현한다. (예를 들어, [1, 2, 3]과 같이 표현할 수 있을 것이다)

 그리고 Scalar는 X와 Y축을 기준으로 설정된 벡터의 좌표값에도 변하지 않는 수를 말한다. 1이나 2, 3처럼 일반적인 숫자 형태로 쓴다. 아래는 사칙연산을 활용하여 Vector와 Scalar를 연산한 결과이다. 본래 선형대수에서는 Vector와 Scalar의 덧셈과 뺄셈은 불가능하다. 그러나 Numpy에서는 어떻게 해서든 가능하도록 만들어두었다. 아래의 코드를 보자.

Vector = np.array([1, 2, 3])
Scalar = 2

print("더하기 : {}".format(Vector + Scalar))
print("빼기 : {}".format(Vector - Scalar))
print("곱하기 : {}".format(Vector * Scalar))
print("나누기 : {}".format(Vector / Scalar))

# 아래는 출력결과이다.
# 더하기 : [3 4 5]
# 빼기 : [-1  0  1]
# 곱하기 : [2 4 6]
# 나누기 : [0.5 1.  1.5]

# 본래는 불가능한 덧셈과 뺄셈의 경우
# 모든 Array 성분에 Scalar값을 더해주는(빼주는) 형태로 계산하였다.

 

(2.2) Vector와 Vector 사이의 연산

 선형대수에서 덧셈과 뺄셈의 경우, 두 행렬의 크기가 동일하다는 전제 하에 Vector와 Vector 사이의 연산을 진행할 때 같은 자리의 성분끼리 계산된다. 반면 곱셈(나눗셈은 잘 모르겠다)은 다르다. A벡터와 B벡터가 존재할 때 A * B 연산을 한다고 가정하자. 이때 A벡터의 열 개수와 B벡터의 행 개수가 같다는 전제 하에 {(A벡터의 i번째 행 성분) * (B벡터의 i번째 열 성분)}의 합으로 계산할 수 있다. 다만 Numpy에서는 같은 위치의 성분끼리 사칙연산을 하는 형태로 계산을 진행한다.

a = np.array([1, 3, 5])
b = np.array([2, 9, 20])
print("더하기 : {}".format(a + b))
print("빼기 : {}".format(a - b))

# 행렬곱이 아닌 성분의 곱 형태로 계산을 진행한다.
print("곱하기 : {}".format(a * b))
print("나누기 : {}".format(a / b))

# 아래는 출력결과이다.
# 더하기 : [ 3 12 25]
# 빼기 : [ -1  -6 -15]
# 곱하기 : [  2  27 100]
# 나누기 : [0.5        0.33333333 0.25      ]

 

(2.3) Array의 Indexing과 Slicing

Array의 Indexing은 기존 Python에서 List를 가지고서 Indexing 하는 것과 유사하다.

W = np.array([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]])

# 0번째 행의 0번째 성분을 추출합니다.
# W 전체 array 중 [1, 2, 3, 4]를 추출하고, 여기에서 0번째 성분인 1을 반환합니다.
print(W[0, 0])

# 결과값 : 1

Slicing 또한 Python과 유사하다.

W = np.array([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]])

# 행의 슬라이싱
# (a) 전체 행렬 W에서 [1, 2, 3, 4], [5, 6, 7, 8]를 추출합니다.
# 추출된 [1, 2, 3, 4], [5, 6, 7, 8]에서 1번째 ~ 2번째 성분을 출력합니다.
a = W[0:2, 1:3]

# (b) 전체 행렬 W에서 [1, 2, 3, 4], [5, 6, 7, 8]를 추출합니다.
# 추출된 [1, 2, 3, 4], [5, 6, 7, 8]에서 1번째 ~ 4번째 성분을 출력합니다.
b = W[0:2, 0:4]

# (c) 전체 행렬 W에서 [1, 2, 3, 4], [5, 6, 7, 8]를 추출하여 출력합니다.
c = W[0:2]

# (d) 전체 행렬 W에서 [1, 2, 3, 4], [5, 6, 7, 8]를 추출합니다.
# 추출된 [1, 2, 3, 4], [5, 6, 7, 8] 전체를 출력합니다.
d = W[0:2, :]

print(a)
print(b)
print(c)
print(d)
print("----------------------구분선-------------------")

# 열의 슬라이싱
# (e) 전체 행렬 W에서 모든 행을 추출합니다.
# 추출된 [1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]에서 2번째 ~ 3번째 성분을 출력합니다.
e = W[0:3, 2:4]

# (f) 전체 행렬 W에서 모든 행을 추출합니다.
# 모든 행을 추출합니다.
# 추출된 [1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]에서 2번째 ~ 3번째 성분을 출력합니다.
f = W[:, 2:4]

print(e)
print(f)

# 아래는 출력결과입니다.
# [[2 3]
#  [6 7]]
# [[1 2 3 4]
#  [5 6 7 8]]
# [[1 2 3 4]
#  [5 6 7 8]]
# [[1 2 3 4]
#  [5 6 7 8]]
# ----------------------구분선-------------------
# [[ 3  4]
#  [ 7  8]
#  [11 12]]
# [[ 3  4]
#  [ 7  8]
#  [11 12]]

 

(2.4) Array의 Broadcasting

Numpy는 특수한 방법으로 연산을 진행하는 경우가 종종 있다. 기본적으로는 연산이 불가능한 형태이지만, Numpy의 임의적인 판단 아래 행 또는 열의 크기를 늘려서 계산한다. 물론 기본적으로 같은 타입의 데이터일 경우에만 연산이 가능하다는 한계가 있으나, 피연산자가 연산 가능하도록 변환이 가능하다면 연산이 가능하다.

 

(2.4.1) N * M과 M * 1(또는 1 * M)의 계산

 이 경우, M * 1의 벡터를 N개만큼 늘려서 계산을 진행한다.

a = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
x = np.array([0, 1, 0])  # 행벡터로 형성됨

# a의 2열인 2, 5, 8에서 1이 더해진 형태로 결과가 출력된다.
print(a + x)

# x의 전치, 열벡터로 만든다.
b = x[:, None] # 행벡터 x가 전치된 열벡터

# a의 2행인 4, 5, 6에서 1이 더해진 형태로 결과가 출력된다.
print(a + b)

# 이하는 출력결과이다.
# [[1 3 3]
#  [4 6 6]
#  [7 9 9]]
# [[1 2 3]
#  [5 6 7]
#  [7 8 9]]

 

(2.4.2) M * 1과 1 * N의 계산

 M * 1을 N개만큼 복사한 뒤, 1 * N을 M개만큼 복사하여 각각의 행렬을 M * N형태로 만들어 계산한다.

t = np.array([1, 2, 3])
t = t[:, None] # transpose

u = np.array([2, 0, -3])

print(t + u)

# 위 결과가 의도한 바와 같은지를 검증한다.
# 벡터t를 벡터u의 열갯수만큼 증가시킨다.
# 벡터u를 벡터t의 행갯수만큼 증가시킨다.
check_t = np.array([[1, 1, 1], [2, 2, 2], [3, 2, 3]])
check_u = np.array([[2, 0, -3], [2, 0, -3], [2, 0, -3]])

print(check_t + check_u)

# 아래는 출력결과이다.
# [[ 3  1 -2]
#  [ 4  2 -1]
#  [ 5  3  0]]
# [[ 3  1 -2]
#  [ 4  2 -1]
# [ 5  2  0]]

(2.5) 그 외 기초적인 행렬처리

(2.5.1) 영벡터

 원소가 모두 0인 벡터이다. 상수와의 곱셈에서 자연수 0을 곱하면 어떤 값을 곱하든 0이 되는 것처럼, 영벡터는 어떤 벡터를 곱하더라도 결과값은 모두 영벡터가 된다.

# np.zeros((행 갯수, 열 갯수))의 형태로 적어준다.
np.zeros((2, 2))

# 출력결과
# array([[0., 0.],
#        [0., 0.]])

 

(2.5.2) 일벡터

 원소가 모두 1인 벡터이다. 단, 상수와의 곱셈에서 자연수 1처럼 대해서는 안 된다. 이거는 단위행렬이라는 별도의 개념이 존재한다. 일벡터는 그냥 원소가 모두 1인 벡터라는 의의 이상의 존재가치를 지니지 않는다.

# np.ones((행 갯수, 열 갯수))의 형태로 적어준다.
np.ones((2, 2))

# 출력결과
# array([[1., 1.],
#        [1., 1.]])

 

(2.5.3) 대각행렬

 주대각성분을 제외한 성분이 모두 0인 행렬이다. 

# np.diag((첫 번째 행에 들어갈 성분, 두 번째 행에 들어갈 성분....))의 형태로 적어준다.
np.diag((2, 4, 5))

# 출력결과
# array([[2, 0, 0],
#        [0, 4, 0],
#        [0, 0, 5]])

 

(2.5.4) 항등행렬

주대각성분이 모두 1인 대각행렬로, 단위행렬이라고도 한다. 상수와의 곱셈에서 자연수 1과 같은데, 항등행렬에 어떤 행렬을 곱하더라도 결국 그 행렬이 나온다. 즉 A * I = A, I * A = A(이때 I는 항등행렬)가 성립한다.

# np.eye(행/열의 개수(정방행렬이므로 숫자는 하나만 적어도 됨), dtype = (데이터의타입, int, float 등으로 defalut값은 float)의 형태로 적어준다.
np.eye(2, dtype = int)

np.eye(3).dtype

# 출력결과
# array([[1, 0],
#       [0, 1]])
#
# dtype('float64')

 

(2.5.5) 행렬곱

행렬 간 곱연산으로, 위에서 말한 "선형대수에서 말하는 곱셈"의 방식으로 계산한다.

# (앞 행렬).dot(뒤 행렬) 혹은 (앞 행렬) @ (뒤 행렬)의 형태로 계산한다.
# 행렬의 곱셈은 극히 일부를 제외하고는 순서가 바뀔 경우 결과값이 변하므로 유의해서 작성한다.

mat_1 = np.array([[1, 4], [2, 3]])
mat_2 = np.array([[7, 9], [0, 6]])

print(mat_1.dot(mat_2))
print(mat_1 @ mat_2)

# 아래는 출력결과이다
# [[ 7 33]
#  [14 36]]
# [[ 7 33]
#  [14 36]]

 

(2.5.6) 트레이스

 주대각성분의 합을 말한다.

# (계산하고자 하는 행렬).trace()과 같이 적는다.

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

print(np.eye(2, dtype = int).trace())
print(arr.trace()) # 1 + 5 + 9 = 15

# 결과값 : 15

 

(2,5,7) 행렬식

 역행렬이나 선형독립 등 행렬에 관련된 여러 가지 사항을 검증하는 데 사용되는 값이다. 선형대수에서는 det(A)라고 적는다.

# "np.linalg.det(계산하고자 하는 행렬)"과 같이 적는다.

arr_2 = np.array([[2, 3], [1, 6]])
print(arr_2)
print(np.linalg.det(arr_2))

# 아래는 출력결과이다.
# array([[2, 3],
#        [1, 6]])
# 9.000000000000002
# 참고 : python자체의 문제로 000000000000002의 값이 더 출력되었으므로 000000000000002은 무시한다.

 

(2.5.8) 역행렬

 행렬A에 대하여 AB = BA = I(항등행렬)을 만족하는 행렬B (A^-1로도 쓴다)이다. 상수와의 곱셈에서 A에게 곱해지는 1/A역할을 수행한다.

# "np.linalg.inv(계산하고자 하는 행렬)"로 작성한다.

mat = np.array([[1, 4], [2, 3]])
mat_inv = np.linalg.inv(mat)
print(mat_inv)

# 검증하기
# 오차를 감안했을 때 단위행렬로 변하는 것을 알 수 있다.
mat @ mat_inv

# 아래는 출력결과이다.
# [[-0.6  0.8]
#  [ 0.4 -0.2]]
#
# array([[ 1.00000000e+00,  0.00000000e+00],
#        [-1.11022302e-16,  1.00000000e+00]])
# 참고 : mat @ mat_inv의 값이 [[1, 0], [0, 1]]의 형태로 딱 떨어지는 않으나
# 거의 근접한 값을 보여주고 있으므로 역행렬을 잘 보여주고 있다고 보아도 무방하다.

 

(2.5.9) 고유값과 고유벡터

 정방행렬 A에 대해서 Ax = (lambda)x를 만족하는 lambda와 x값을 각각 고유값과 고유벡터라고 한다.

# "np.linalg.eig(계산하고자 하는 행렬)"과 같이 작성한다.

mat = np.array([[2, 0, -2], [1, 1, -2], [0, 0, 1]])

eig_val, eig_vec = np.linalg.eig(mat)
print(mat @ eig_vec[:, 0]) # Ax

print(eig_val[0] * eig_vec[:, 0]) # (lambda)x

# 아래는 출력결과이다.
# [0. 1. 0.]
# [0. 1. 0.]

# Ax와 (lambda)x가 모두 동일한 값을 보여주고 있으므로
# 고유값과 고유벡터가 훌륭히 출력되었다고 볼 수 있다.

3. Exercises

3.1. 어떤 벡터가 주어졌을 때 L2 norm을 구하는 함수 get_L2_norm()을 작성하시오

조건1. 매개변수 : 1차원 벡터(np.array)로 할 것

조건2. 반환값 : 인자로 주어진 벡터의 L2 Norm값 (number)

 

 L2 norm은 유클리드 노름을 의미한다. 유클리드 노름은 우리가 중학생 때 피타고라스의 정리를 통해서 직각삼각형의 대각선 길이를 구했던 것과 같은 원리로, 각 성분의 값의 제곱을 모두 더해서 루트를 씌운 값과 같다. 그러므로 이 문제를 풀기 위해서는 대각선으로 향하는 벡터의 크기를 구하기 위해서 sqrt(sum(각 성분의 제곱))을 이용하면 된다.

def get_L2_norm(array) :
    temp_num = 0
    number = 0
    for i in range(len(array)):
        temp_num = array[i] ** 2
        number += temp_num
    return number


# 위 함수가 옳게 작동하는지 아래의 test_case를 이용해서 검증한다.
vector1 = np.array([1, 2, 3]) # 14
vector2 = np.array([4, 5, 8]) # 105
vector3 = np.array([1, 2, 3, 4, 5, 6, 0]) # 91
vector4 = np.array([1, 3, 5, 7, 9]) # 165
print(get_L2_norm(vector1))
print(get_L2_norm(vector2))
print(get_L2_norm(vector3))
print(get_L2_norm(vector4))

# 아래는 출력결과이다. 예상된 값과 동일하게 출력되는 것을 알 수 있다.
# 14
# 105
# 91
# 165

 

3.2. 어떤 행렬이 singular matrix인지 확인하는 함수 is_singular()를 작성하시오

조건1. 매개변수 : 2차원 벡터 (np.array)

조건 ; 반환값 : 인자로 주어진 벡터가 singular라면 True, non_singular라면 False를 반환한다.

 singular란 특이행렬이라는 뜻으로, 특이행렬은 역이 존재하지 않는 행렬을 말한다. 어떠한 행렬A에서 역이 존재하지 않기 위해서는(비가역행렬이기 위해서는) 행렬식이 0이어야 한다. 반대로 역이 존재하기 위해서는(가역행렬이기 위해서는) 행렬식의 값이 0이 아니어야 한다.

def is_singular(array):
    if np.linalg.det(array) == 0:
        return True
    else:
        return False
    
vector1 = np.array([[1, 2], [3, 4]]) # False
vector2 = np.array([[1, 3], [5, 7]]) # False
vector3 = np.array([[1, 0], [0, 1]]) # False
vector4 = np.array([[4, 5], [4, 5]]) # True

print(is_singular(vector1))
print(is_singular(vector2))
print(is_singular(vector3))
print(is_singular(vector4))

# 이하는 출력결과이다. 예상값과 동일하게 출력되는 것을 알 수 있다.
# False
# False
# False
# True