CNN 흉내내기

5x5 크기의 문자 리스트를 사용해서 CNN 유사 모델 구현
다만 데이터셋이 숫자당 1개밖에 없기 때문에 제대로 된 학습은 불가능.
그럼에도 불구하고 상당한 수준으로 예측한다.

CNN의 후반부를 구성하는 FC에 대해 정확하게 이해할 수 있는 훌륭한 예제이다.
이전 예제에서 멀티 레이어를 구성하는 방법을 배웠다면,
여기서는 레이어에 포함된 뉴런의 의미와 갯수를 비롯해서 정답을 찾아가기 위해 어떻게 변환하는지 확인할 수 있다.

원본 코드
https://github.com/joelgrus/data-science-from-scratch/blob/master/code/neural_networks.py

원본에서는 코드가 흩어져 있거나 내 생각과 다른 부분이 있어서
새롭게 정리하고 일부 코드를 추가했다.
그 과정에서 코드가 너무 길어졌고, 읽기 편하도록 두 개로 분리했다.

아래 코드는 역전파(back-propagation) 알고리즘을 사용해서 학습하고,
마지막에는 전달된 데이터에 대해 예측까지 보여준다.
핵심 코드는 back_propagate 함수에 있고,
뉴런에 들어있는 weights를 진짜로 변경하는 것이 어렵지 않다는 것을 보여준다.
sigmoid,neuron_output,feed_forward 함수는 다른 문서에서도 함께 사용하는 공통 코드이다.
"밑바닥부터 시작하는 데이터 과학" 8장에 있는 "CAPTCHA깨기"에서 발췌한 코드를
세분화시켜서 여러 개의 문서로 만드는 과정에서 사용된 공통 코드이다.

import LinAlg                   # from linear_algebra import dot
import math, random

def sigmoid(t):
return 1 / (1 + math.exp(-t))

def neuron_output(weights, inputs):
return sigmoid(LinAlg.dot(weights, inputs))

def feed_forward(neural_network, input_vector):
outputs = []
for layer in neural_network:
input_with_bias = input_vector + [1]
output = [neuron_output(neuron, input_with_bias) for neuron in layer]

outputs.append(output)
input_vector = output

return outputs

def back_propagate(network, input_vector, target):
''' network에 들어있는 히든과 출력 레이어에 포함된 neuron의 weights를 변경.
히든과 출력 레이어에 포함된 뉴런의 weights를 변경할 수 있다는 것이 핵심.
변경해야 하는 올바른 방향을 제시할 수 있다면, 항상 정답을 찾아가는 것을 보장한다. '''
# unpack. network이 2개짜리 리스트라고 가정하고 있다. 히든과 출력 레이어.
hidden_layer, output_layer = network

# network이 2개짜리 레이어이므로
# feed_forward 함수 안에서 2회 반복하게 되고, 반환값 또한 2개짜리가 된다.
hidden_outputs, outputs = feed_forward(network, input_vector)

# 아래에 똑같은 코드를 2회 반복한다. 2회라서 반복문으로 구성하지 않고 나열해서 처리한다.
# 레이어가 많아진다면 반드시 반복문으로 꾸며야 한다.

# output * (1 - output)은 sigmoid의 미분. output_deltas는 1차원 10개짜리. (10,)
# output_deltas는 히든 레이어에 들어있는 뉴런과 계산할 weights.
output_deltas = [output * (1 - output) * (output - t) for output, t in zip(outputs, target)]

# 출력 레이어의 뉴런에 대해 weight 조정. output_neuron은 리스트이기 때문에 직접 수정할 수 있다.
for i, output_neuron in enumerate(output_layer):
for j, hidden_output in enumerate(hidden_outputs + [1]):
output_neuron[j] -= output_deltas[i] * hidden_output

# 오류값을 히든 레이어로 전파(뒤로 전파). output_layer는 10행 6열, hidden_outputs는 1차원 5개짜리 (5,)
# weight에 대해서만 행렬 곱셈을 수행하고 bias에 대해서는 아무 것도 하지 않는다.
# [n[i] for n in output_layer]는 행렬을 transpose시킨다는 뜻.
# 결국 10x10 inner product 수행하고 1개의 결과 리턴.
# dot 함수는 2차원에 대해서는 행렬 곱셈, 1차원에 대해서는 inner produce 수행.
hidden_deltas = [hidden_output * (1 - hidden_output) *
LinAlg.dot(output_deltas, [n[i] for n in output_layer])
for i, hidden_output in enumerate(hidden_outputs)]

# 히든 레이어의 뉴런에 대해 weight 조정. 출력 레이어 조정과 완전히 같은 코드.
for i, hidden_neuron in enumerate(hidden_layer):
for j, input in enumerate(input_vector + [1]):
hidden_neuron[j] -= hidden_deltas[i] * input

def train(network, inputs, target, loops):
# 사용하지 않는 코드. weights가 바뀌는지 확인하는 용도로 사용
hidden_layer, output_layer = network # (5, 26), (10, 6)
for _ in range(loops):
for input_vector, target_vector in zip(inputs, target):
back_propagate(network, input_vector, target_vector)
# print(hidden_layer[0][:2], output_layer[0][:2]) # 반복할 때마다 달라지는 값 표시

def show_result(network, shape):
def predict(network, input):
# _, outputs = feed_forward(network, input)
# return outputs
# -1만 사용한다는 것은 두 개 중에서 처음 것은 placeholder로 처리한다는 뜻.
return feed_forward(network, input)[-1]

for i, v in enumerate(shape):
print('@' if v else ' ', end='')
if i%5 == 4:
print()

# 10개의 숫자 중에서 가장 큰 숫자의 인덱스 출력
result = [round(x, 3) for x in predict(network, shape)]
pos = result.index(max(result))
print('predict {}, {:.1f}% : {}'.format(pos, 100*result[pos], result))
print()


inputs = [[1, 1, 1, 1, 1,
1, 0, 0, 0, 1,
1, 0, 0, 0, 1,
1, 0, 0, 0, 1,
1, 1, 1, 1, 1],
[0, 0, 1, 0, 0,
0, 0, 1, 0, 0,
0, 0, 1, 0, 0,
0, 0, 1, 0, 0,
0, 0, 1, 0, 0],
[1, 1, 1, 1, 1,
0, 0, 0, 0, 1,
1, 1, 1, 1, 1,
1, 0, 0, 0, 0,
1, 1, 1, 1, 1],
[1, 1, 1, 1, 1,
0, 0, 0, 0, 1,
1, 1, 1, 1, 1,
0, 0, 0, 0, 1,
1, 1, 1, 1, 1],
[1, 0, 0, 0, 1,
1, 0, 0, 0, 1,
1, 1, 1, 1, 1,
0, 0, 0, 0, 1,
0, 0, 0, 0, 1],
[1, 1, 1, 1, 1,
1, 0, 0, 0, 0,
1, 1, 1, 1, 1,
0, 0, 0, 0, 1,
1, 1, 1, 1, 1],
[1, 1, 1, 1, 1,
1, 0, 0, 0, 0,
1, 1, 1, 1, 1,
1, 0, 0, 0, 1,
1, 1, 1, 1, 1],
[1, 1, 1, 1, 1,
0, 0, 0, 0, 1,
0, 0, 0, 0, 1,
0, 0, 0, 0, 1,
0, 0, 0, 0, 1],
[1, 1, 1, 1, 1,
1, 0, 0, 0, 1,
1, 1, 1, 1, 1,
1, 0, 0, 0, 1,
1, 1, 1, 1, 1],
[1, 1, 1, 1, 1,
1, 0, 0, 0, 1,
1, 1, 1, 1, 1,
0, 0, 0, 0, 1,
1, 1, 1, 1, 1]]


# [[1, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
# [0, 0, 1, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 1, 0, 0, 0, 0, 0, 0],
# [0, 0, 0, 0, 1, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 1, 0, 0, 0, 0],
# [0, 0, 0, 0, 0, 0, 1, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 1, 0, 0],
# [0, 0, 0, 0, 0, 0, 0, 0, 1, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 1]]
target = [[1 if i == j else 0 for i in range(10)] for j in range(10)]

random.seed(0) # seed 고정

input_size = 25 # 그림 크기 5x5
num_hidden = 5 # 히든 레이어 뉴런의 갯수
output_size = 10 # 0~9까지 10개 숫자

# +1은 bias. 초기값은 난수로 설정
# hidden_layer : 5x26, 25개의 입력, 5개의 출력
# output_layer : 10x6, 5개의 입력, 10개의 출력
hidden_layer = [[random.random() for _ in range(input_size + 1)] for _ in range(num_hidden)]
output_layer = [[random.random() for _ in range(num_hidden + 1)] for _ in range(output_size)]

network = [hidden_layer, output_layer]

# 충분히 수렴할 만큼 반복 횟수 지정.
# 10회는 모든 weight이 같고, 100회는 아주 조금 달라지고 1000회는 많이 달라진다.
train(network, inputs, target, 10)

# 입력으로 사용했던 10개의 숫자에 대해 정확하게 동작하는지 확인하는 코드.
# for input in inputs:
# show_result(network, input)

shape_1 = [0, 1, 1, 1, 0, # .@@@.
0, 0, 0, 1, 1, # ...@@
0, 0, 1, 1, 0, # ..@@.
0, 0, 0, 1, 1, # ...@@
0, 1, 1, 1, 0] # .@@@.
shape_2 = [0, 1, 1, 1, 0, # .@@@.
1, 0, 0, 1, 1, # @..@@
0, 1, 1, 1, 0, # .@@@.
1, 0, 0, 1, 1, # @..@@
0, 1, 1, 1, 0] # .@@@.

# 입력에 포함되지 않았던 데이터를 생성해서 예측하기.
show_result(network, shape_1) # 3 예측
show_result(network, shape_2) # 9 예측. 정답은 8인데 잘못 예측.

# [출력 결과]
# train 함수 10,000번 반복했을 때의 show_result 호출 결과.
# @@@
# @@
# @@
# @@
# @@@
# predict 3, 93.4% : [0.0, 0.002, 0.0, 0.934, 0.0, 0.0, 0.0, 0.007, 0.0, 0.1]
#
# @@@
# @ @@
# @@@
# @ @@
# @@@
# predict 9, 99.6% : [0.0, 0.0, 0.0, 0.0, 0.0, 0.536, 0.0, 0.0, 0.915, 0.996]

# train 함수 100,000번 반복했을 때의 show_result 호출 결과.
# 여전히 9를 예측하고 있고, 오히려 확률이 높아졌다.
# predict 3, 98.8% : [0.0, 0.003, 0.0, 0.988, 0.0, 0.0, 0.0, 0.006, 0.0, 0.025]
# predict 9, 99.9% : [0.0, 0.0, 0.0, 0.0, 0.0, 0.675, 0.0, 0.0, 0.941, 0.999]


아래 코드는 앞에서 학습한 결과를 그래프로 표시한다.
히든 레이어의 뉴런에 들어있는 weights가 어떻게 바뀌었는지 상세하게 표시하고,
최종적으로 모든 뉴런을 같은 그래프에 출력해서 비교할 수 있도록 제시한다.
이 코드는 위에 나온 코드와 같은 파일에 있어야 동작한다.

import matplotlib
import matplotlib.pyplot as plt
def patch(x, y, hatch, color):
# hatch : 빗금 사용
# color : 빗금 색상
# fill : 전체를 채울 것인지. True라면 빗금 사용할 수 없다.
return matplotlib.patches.Rectangle((x - 0.5, y - 0.5), 1, 1,
hatch=hatch, color=color, fill=False)

def show_weights_detail(weights):
''' 히든레이어 뉴런 하나를 자세하게 출력 '''
abs_weights = list(map(abs, weights))

# 25개로 구성된 1차원 리스트를 5x5 리스트로 변환
grid = [abs_weights[row:row+5] for row in range(0,25,5)]

# 그래프 6개 표시. 왼쪽 2개는 원본(빨강, 파랑) 이미지에 대해 적용. 가운데 2개는 회색 계열 colormap 적용.
# 오른쪽 2개는 interpolation(보간) 제거 및 음수 빗금 표시
plt.subplot(231)
plt.imshow(grid) # None : rc image.interpolation
plt.subplot(234)
plt.imshow(grid, interpolation='none')

plt.subplot(232)
plt.imshow(grid, cmap=matplotlib.cm.binary)
plt.subplot(235)
plt.imshow(grid, cmap='gray')
plt.subplot(233)
plt.imshow(grid, cmap=matplotlib.cm.binary, interpolation='none')

# interpolation 옵션
# Acceptable values are
# ‘none’, ‘nearest’, ‘bilinear’, ‘bicubic’, ‘spline16’, ‘spline36’, ‘hanning’, ‘hamming’,
# ‘hermite’, ‘kaiser’, ‘quadric’, ‘catrom’, ‘gaussian’, ‘bessel’, ‘mitchell’, ‘sinc’, ‘lanczos’
#
# If interpolation is None, default to rc image.interpolation.
# See also the filternorm and filterrad parameters.
# If interpolation is ‘none’, then no interpolation is performed on the Agg,
# ps and pdf backends.Other backends will fall back to ‘nearest’.

# 음수 weights에 대해 빗금 표시
plt.subplot(236)
plt.imshow(grid, cmap=matplotlib.cm.binary, interpolation='none')
ax = plt.gca()

for i in range(5):
for j in range(5):
# 음수에 대해서만 빗금 표시
if weights[5*i + j] < 0:
ax.add_patch(patch(j, i, '/', 'red'))
ax.add_patch(patch(j, i, '\\', 'red'))
plt.figure(1).suptitle('show_weights_detail', fontsize=24)
plt.show()

def show_weights_all(hidden_layer):
''' 히든레이어 뉴런 전체 출력 '''
for i, weights in enumerate(hidden_layer):
plt.subplot(1, len(hidden_layer), i+1)
abs_weights = list(map(abs, weights))

# 25개로 구성된 1차원 리스트를 5x5 리스트로 변환
grid = [abs_weights[row:row+5] for row in range(0,25,5)]

ax = plt.gca()
plt.imshow(grid, cmap=matplotlib.cm.binary, interpolation='none')

for i in range(5):
for j in range(5):
if weights[5*i + j] < 0:
ax.add_patch(patch(j, i, '/', 'red'))
ax.add_patch(patch(j, i, '\\', 'red'))
plt.figure(1).suptitle('show_weights_all', fontsize=24)
plt.show()


# 히든레이어는 5x26이기 때문에 5개까지만 출력 가능.
show_weights_detail(hidden_layer[0])
# show_weights_detail(hidden_layer[1])

show_weights_all(hidden_layer)


아래는 show_weights_detail 함수를 히든 레이어의 0번 뉴런에 대해 호출한 결과이다.
weight에는 음수와 양수가 있는데, 이 값을 색상으로 표현하고자 하는게, 이번 코드의 목적이다.
각각의 weight은 음수와 양수 범위에 대해 매우 다양하게 나타나는데,
이 값은 어떻게 해석되어야 맞는 것일까?

처음 코드에 있는 neuron_output 함수에서 답을 찾을 수 있다.
이 함수는 sigmoid에 곱해서 더한 결과(inner product 또는 행렬 곱셈)를 sigmoid 함수에 전달하는데,
sigmoid 함수의 결과는 -1에서 1 사이의 값이다.
0보다 크면 활성화되고, 0보다 작으면 비활성화되는 activation 함수 중의 하나다.
그래서, 활성화시키는 값은 좋게, 비활성화시키는 값은 나쁘게 평가할 수 있다.
즉, 양수는 좋은 쪽으로, 음수는 나쁜 쪽으로 해석하면 된다.

RGB로 표현된 그래프에서는
파랑이 절댓값이 작은 경우이고, 나머지 색상은 절댓값이 큰 경우이다.
이 값을 흑백으로 변경해서 음수에는 빗금을 표시했다.
빗금이 그려진 그림을 보면, 0번 뉴런에 대한 이해를 조금 높일 수 있다.
완전하진 않지만, weights가 어떤 숫자들로 배치되는지 이해할 수 있게 된다.
가령, 음수는 0, 2, 4번째 수직 줄에서 많이 나타난다.
양수는 0번째 수직 줄에서 큰 값을 갖고, 음수는 4번째 수직 줄에서 조금 큰 값을 갖는다.

일반적으로 weight가 0에 가까울수록 흰색이고,
양수이면서 절댓값이 크면 녹색, 음수이면서 절댓값이 크면 빨강에 가깝게 된다.
이것을 흑백으로 변경하면, 흰색은 바뀌지 않지만,
절댓값이 커질수록 짙은 색으로 표시하게 된다.
이때 양수와 음수를 구분할 수 없기 때문에 여기서는 빗금을 사용해서 처리했다.

그렇지만 딥러닝에서 히든 레이어에 대해 예측할 수 없는 문제가 있다고 말하는 것처럼
논리적으로 해석할 수 없는 경우가 더 많을 수도 있다.
사실 0번 뉴런도 해석했다고 보기는 어려운게 사실이다.
답이 나오는 것은 알지만,
어떤 과정을 거쳐서 나오는지 확신하지 못하는 것은 딥러닝의 단점 중에 하나일 수밖에 없다.
답답하니까.


아래는 show_weights_all 함수를 호출한 결과이다.
히든 레이어의 5개 뉴런에 대해 모두 그래프로 표시하고 있다.
앞에서 장황하게 설명했던 것처럼
왜 음수가 나오는지, 왜 양수가 나오는지 잘 모른다.
1번과 3번 뉴런은 놀랍게도 음수값이 하나도 없지만 왜 그런지는 알 수 없다.
우리가 아는 것은
좋은 데이터로 많은 학습을 시키면 답이 될 확률이 높다는 것뿐이다.