Tag Archives: 머신러닝

케라스 창시자에게 배우는 딥러닝/ 머신 러닝의 기본 요소

머신 러닝의 네 가지 분류

  • 앞서 다뤘던 세 종류의 머신 러닝 문제는 이진 분류, 다중 분류, 스칼라 회귀였다. 이들은 모두 지도 학습(supervised learning)의 예이다. 지도 학습의 목표는 훈련 데이터 입력과 타깃 사이의 관계를 학습하는 것이다.
  • 지도 학습은 머신 러닝 알고리즘의 일부일 뿐이고, 머신 러닝은 크게 4개의 커다란 범주로 나눈다.

지도 학습

  • 가장 흔한 경우로 (종종 사람이 레이블링한) 샘플 데이터가 주어지면 알고 있는 타깃(꼬리표(annotation))에 입력 데이터를 매핑하는 방법을 학습한다.
  • 요즘 각광받는 광학 문자 판독, 음성 인식, 이미지 분류, 언어 번역 같은 딥러닝의 거의 모든 애플리케이션이 일반적으로 이 범주에 속한다.
  • 지도 학습은 대부분 분류와 회귀로 구성되지만 다음과 같은 특이한 변종도 있다.
    • 시퀀스 생성(sequence generation): 사진이 주어지면 이를 설명하는 캡션을 생성한다. 시퀀스 생성은 이따금 일련의 분류 문제로 재구성할 수 있다.
    • 구문 트리(syntax tree) 예측: 문장이 주어지면 분해된 구문 트리를 예측한다.
    • 물체 감지(object detection) 사진이 주어지면 사진 안의 특정 물체 주위에 경계 상자(bounding box)를 그린다. 이는 분류 문제로 표현되거나 경계 상자의 좌표를 벡터 회귀로 예측하는 회귀와 분류가 결합된 문제로 표현할 수 있다.
    • 이미지 분할(image segmentation): 사진이 주어졌을 때 픽셀 단위로 특정 물체에 마스킹(masking)을 한다.

비지도 학습

  • 이 부류의 머신 러닝은 어떤 타깃도 사용하지 않고 입력 데이터에 대한 흥미로운 변환을 찾는다. 데이터 시각화, 데이터 압축, 데이터의 노이즈 제거 또는 데이터에 있는 상관관계를 더 잘 이해하기 위해 사용한다.
  • 비지도 학습(unsupervised learning)은 데이터 분석에서 빼 놓을 수 없는 요소이며, 종종 지도 학습 문제를 풀기 전에 데이터셋을 잘 이해하기 위해 필수적으로 거치는 단계이다.
  • 차원 축소(dimensionality reduction)와 군집(clustering)이 비지도 학습에서 잘 알려진 범주이다.

자기 지도 학습

  • 자기 지도 학습(self-supervised learning)은 지도 학습의 특별한 케이스지만 별도의 범주로 할 만큼 충분히 다르다.
    • 자기 지도 학습은 지도 학습이지만 사람이 만든 레이블을 사용하지 않는다. 즉 학습 과정에 사람이 개입하지 않는 지도 학습이라고 생각할 수 있다.
    • 레이블이 여전히 필요하지만 보통 경험적인 알고리즘(heuristic algorithm)을 사용해서 입력 데이터로부터 생성한다.
  • 예컨대 오토인코더(autoencoder)가 잘 알려진 자기 지도 학습의 예이다.
    • 여기서 생성된 타깃은 수정하지 않은 원본 입력이다. 같은 방식으로 지난 프레임이 주어졌을 때 비디오의 다음 프레임을 예측하는 것이나, 이전 단어가 주어졌을 때 다음 단어를 예측하는 것이 자기 지도 학습의 예이다. (이 경우 미래의 입력 데이터로부터 지도되기 때문에 시간에 따른 지도학습(temporally supervised learning)이다)
  • 지도 학습, 자기 지도 학습, 비지도 학습의 구분은 가끔 모호할 수 있다.
    • 이 범주들은 명확한 경계가 없고 연속적이다.
    • 자기 지도 학습은 학습 메커니즘과 애플리케이션 측면 중에 어디에 중점을 두는지에 따라 지도 학습 또는 비지도 학습으로 재해석 될 수 있다.

강화 학습

  • 강화 학습(reinforcement learning)은 구글 딥마인드가 아타리 게임 플레이를 학습하는데 성공적으로 적용하면서 최근 많은 관심을 받기 싲가했다.
    • 강화 학습에서 에이전트(agent)는 환경에 대한 정보를 받아 보상을 최대화하는 행동을 선택하도록 학습된다.
    • 예컨대 강화 학습으로 훈련된 신경망은 비디오 게임 화면을 입력으로 받고 게임 점수를 최대화하기 위한 게임 내 행동을 출력할 수 있다.
  • 현재 강화 학습은 대부분 연구 영역에 속해 있고 게임 이외의 실제적인 성공 사례는 아직 없다. 
    • 하지만 때가 되면 강화 학습이 실제 세상의 많은 애플리케이션을 대체할 것으로 기대하는데, 이런 애플리케이션의 예로는 자율 주행 자동차, 자원 관리, 교육 등이 있다.
  • Note) 분류와 회귀에서 사용하는 용어
    • 샘플 또는 입력: 모델에 주입될 하나의 데이터 포인트
    • 예측 또는 출력: 모델로부터 나오는 값
    • 타깃: 정답. 외부 데이터 소스에 근거하여 모델이 완벽하게 예측해야 하는 값
    • 예측 오차 또는 손실 값: 모델의 예측과 타깃 사이의 거리를 측정한 값
    • 클래스: 분류 문제에서 선택할 수 있는 가능한 레이블의 집합. 예컨대 고양이와 강아지 사진을 분류할 때 클래스는 ‘고양이’와 ‘강아지’ 2개이다.
    • 레이블: 분류 문제에서 클래스 할당의 구체적인 사례. 예컨대 사진 #1234에 ‘강아지’ 클래스가 들어 있다고 표시한다면 ‘강아지’는 사진 #1234의 레이블이 된다.
    • 참 값(ground-truth) 또는 꼬리표(annotation): 데이터셋에 대한 모든 타깃. 일반적으로 사람에 의해 수집된다.
    • 이진 분류: 각 입력 샘플이 2개의 배타적인 범주로 구분되는 분류 작업
    • 다중 분류: 각 입력 샘플이 2개 이상의 범주로 구분되는 분류 작업. 예컨대 손글씨 숫자 분류
    • 다중 레이블 분류: 각 입력 샘플이 여러 개의 레이블에 할당될 수 있는 분류 작업. 예컨대 하나의 이미지에 고양이와 강아지가 모두 들어 있을 때는 ‘고양이’ 레이블과 ‘강아지’ 레이블을 모두 할당해야 한다.
    • 스칼라 회귀: 타깃이 연속적인 스칼라 값인 작업. 주택 가격 예측이 그 예이다.
    • 벡터 회귀: 타깃이 연속적인 값의 집합인 작업. 예컨대 연속적인 값으로 이루어진 벡터이다. (이미지에 있는 경계 상자의 좌표 같은) 여러 개의 값에 대한 회귀를 한다면 벡터 회귀이다.
    • 미니 배치 또는 배치: 모델에 의해 동시에 처리되는 소량의 샘플 묶음(일반적으로 8개에서 128개 사이). 샘플 개수는 GPU의 메모리 할당이 용이하도록 2의 거듭제곱으로 하는 경우가 많다. 훈련할 때 미니 배치마다 한 번씩 모델의 가중치에 적용할 경사 하강법 업데이트 값을 계산한다.

머신 러닝 모델 평가

  • 3장에서 본 3개의 예제에서 데이터를 훈련 세트, 검증 세트, 테스트 세트로 나누었다. 훈련에 사용된 동일한 데이터로 모델을 평가하지 않은 이유는 과대적합 때문이다.
    • 훈련 데이터의 성능은 훈련이 진행될 수록 항상 증가되지만, 새로운 데이터에 대한 성능은 좋아지지 않는다. 또는 더 나빠진다.
  • 머신 러닝의 목표는 처음 본 데이터에서 잘 작동하는 일반화된 모델을 얻는 것이다. 여기서 과대적합은 주요 장애물이다.
    • 관측할 수 있는 것만 제어할 수 있으므로 모델의 일반화 성능에 대한 신뢰를 측정할 수 있는 방법이 아주 중요하다.
    • 다음 절에서 과대 적합을 완화하고 일반화를 최대화하기 위한 전략을 살펴보자.

훈련, 검증, 테스트 세트

  • 모델 평가의 핵심은 가용한 데이터를 항상 훈련, 검증, 테스트 3개의 세트로 나누는 것이다. 훈련 세트에서 모델을 훈련하고 검증 세트에서 모델을 평가한다. 모델을 출시할 준비가 되면 테스트 세트에서 최종적으로 딱 한 번 모델을 테스트한다.
  • 훈련 세트와 테스트 세트를 2개 사용하지 않는 이유는 모델을 개발할 때 항상 모델의 설정을 튜닝하기 때문이다.
    • 예컨대 층의 수나 층의 유닛 수를 선택한다. (이런 파라미터를 네트워크의 가중치와 구분하기 위해 하이퍼파라미터(hyperparameter)라고 부른다) 검증 세트에서 모델의 성능을 평가하여 이런 튜닝을 수행한다.
    • 본질적으로 이런 튜닝도 어떤 파라미터 공간에서 좋은 설정을 찾는 학습이다. 결국 검증 세트의 성능을 기반으로 모델의 설정을 튜닝하면 검증 세트로 모델을 직접 훈련하지 않더라도 빠르게 검증 세트에 과대적합될 수 있다.
  • 이 현상의 핵심은 정보 누설(information leak) 개념에 있다. 
    • 검증 세트의 모델 성능에 기반하여 모델의 하이퍼파라미터를 조정할 때마다 검증 데이터에 관한 정보가 모델로 새는 것이다.
    • 하나의 파라미터에 대해서 단 한번만 튜닝한다면 아주 적은 정보가 누설된다. 이런 검증 세트로는 모델을 평가할만하다.
    • 하지만 한 번 튜닝하고 나서 검증 세트에 평가한 결과를 가지고 다시 모델을 조정하는 과정을 여러 번 반복하면 검증 세트에 관한 정보를 모델에 아주 많이 노출시키게 된다.
    • 결국 검증 데이터에 맞추어 최적화했기 때문에 검증 데이터에 의도적으로 잘 수행되는 모델이 만들어진다.
    • 검증 데이터가 아니고 완전히 새로운 데이터에 대한 성능이 관심 대상이라면 모델을 평가하기 위해 이전에 본 적 없는 완전히 다른 데이터셋을 사용해야 한다. 바로 테스트 세트이다.
    • 모델은 간접적으로라도 테스트 세트에 대한 어떤 정보도 얻어서는 안된다. 테스트 세트 성능에 기초하여 튜닝한 모델의 모든 설정은 일반화 성능을 왜곡시킬 것이다.
  • 데이터를 훈련, 검증, 테스트 세트로 나누는 것은 간단해 보일 수 있지만, 데이터가 적을 때는 몇 가지 고급 기법을 사용하면 도움이 된다. 대표적인 세 가지 평가 방법인 단순 홀드아웃 검증(hold-out validation), K-겹 교차 검증(K-fold cross-validation), 셔플링(shuffling)을 사용한 K-겹 교차 검증(iterated K-fold cross-validation)을 살펴보겠다.

단순 홀드아웃 검증

  • 데이터의 일정량을 테스트 세트로 떼어 놓는다. 남은 데이터에서 훈련하고 테스트 세트로 평가한다.
    • 앞서 설명했듯이 정보 누설을 막기 위해 테스트 세트를 사용해서 모델을 튜닝해서는 안되기 때문에 검증 세트도 따로 떼어 놓아야 한다.
    • 홀드아웃 검증은 아래 그림과 같다.

num_validation_samples = 10000

np.random.shuffle(data)

validation_data = data[:num_validation_samples]
data = data[num_validation_samples:]

training_data = data[:]

# 훈련세트에서 모델을 훈련하고 검증 세트로 평가한다.
model = get_model()
model.train(training_data)
validation_score = model.evaluate(validation_data)

# 여기에서 모델을 튜닝하고
# 다시 훈련하고 평가하고, 다시 튜닝하고...

# 하이퍼파라미터 튜닝이 끝나면 테스트 데이터를 제외한 모든 데이터를 사용하여 모델을 다시 훈련시킨다.
model = get_model()
model.train(np.concatenate([training_data, validation_data]))
test_score = model.evaluate(test_data)
  • 이 평가 방법은 단순해서 한 가지 단점이 있는데, 데이터가 적을 때는 검증 세트와 테스트 세트의 샘플이 너무 적어 주어진 전체 데이터를 통계적으로 대표하지 못할 수 있다.
    • 이를 쉽게 확인하는 방법은 다른 난수 초깃값으로 셔플링해서 데이터를 나누었을 때 모델의 성능이 매우 달라지는지를 확인하는 것이다.
    • 이 문제는 이어서 설명할 K-겹 교차 검증과 반복 K-겹 교차 검증이 해결할 수 있다.

K-겹 교차 검증

  • 이 방식에서는 데이터를 동일한 크기를 가진 K개 분할로 나눈 후, 각 분할 i에 대해 남은 K-1개의 분할로 모델을 훈련하고 분할 i에서 모델을 평가한다. 최종 점수는 이렇게 얻은 K개의 점수를 평균한다.
    • 이 방법은 모델의 성능이 데이터 분할에 따라 편차가 클 때 도움이 된다.
    • 홀드아웃 검증처럼 모델의 튜닝에 별개의 검증 세트를 사용하게 된다.

k = 4
num_validation_samples = len(data) // k

np.random.shuffle(data)

validation_scores = []

for fold in range(k):
# 검증 데이터 부분을 선택한다.
validation_data = data[num_validation_samples * fold: num_validation_samples * (fold+1)]
# 남은 데이터를 훈련 데이터로 사용한다.
training_data = data[:num_validation_samples * fold] + data[num_validation_samples * (fold+1):]

# 훈련되지 않은 새로운 모델을 만든다.
model = get_model()
model.train(training_data)
validation_score = model.evaluate(validation_data)
validation_scores.append(validation_score)

# 검증 점수: K개 폴드의 검증 점수 평균
validation_score = np.average(validation_scores)

# 테스트 데이터를 제외한 전체 데이터로 최종 모델을 훈련한다.
model = get_model()
model.train(data)
test_score = model.evaluate(test_data)

셔플링을 사용한 반복 K-겹 교차 검증

  • 이 방법은 비교적 가용 데이터가 적고 가능한 정확하게 모델을 평가하고자 할 때 사용한다. 캐글 경연에서는 이 방법이 아주 크게 도움이 된다.
    • 이 방법은 K-겹 교차 검증을 여러 번 적용하되 K개의 분할로 나누기 전에 매번 데이터를 무작위로 섞는다.
    • 최종 점수는 모든 K-겹 교차 검증을 실행해서 얻은 점수의 평균이 된다.
    • 결국 P x K개(P는 반복 횟수)의 모델을 훈련하고 평가하므로 비용이 매우 많이 든다.

기억해야 할 것

  • 평가 방식을 선택할 때 다음 사항을 유념해야 한다.
    • 대표성 있는 데이터
      • 훈련 세트와 테스트 세트가 주어진 데이터에 대한 대표성이 있어야 한다. 숫자 이미지 분류하는 문제에서 80:20으로 훈련과 테스트 세트를 만들면 훈련 세트에는 0-7까지의 숫자가 있고 테스트 세트에는 8-9 숫자가 담기게 된다. 이런 어처구니 없는 실수는 놀랍게도 자주 일어난다.
    • 시간의 방향
      • 과거로부터 미래를 예측하고자 한다면 데이터를 분할하기 전에 무작위로 섞어서는 안된다. 이러면 미래의 정보가 누설되기 때문이다. 즉 모델이 사실상 미래 데이터에서 훈련될 것이다.
    • 데이터 중복
      • 한 데이터셋에서 어떤 데이터 포인트가 두 번 등장하면 데이터를 섞고 훈련 세트와 검증 세트로 나누었을 때 훈련 세트와 검증 세트에 데이터 포인트가 중복될 수 있다. 이로 인해 훈련 데이터의 일부로 테스트하는 최악의 경우가 발생한다.

데이터 전처리, 특성 공학, 특성 학습

  • 많은 데이터 전처리와 특성 공학 기법은 특정 도메인에 종속적이기 때문에, 여기서는 모든 종류의 데이터에 공통되는 기본 사항을 살펴보겠다.

신경망을 위한 데이터 전처리

  • 데이터 전처리 목적은 주어진 원본 데이터를 신경망에 적용하기 쉽도록 만드는 것이다. 벡터화(vectorization), 정규화(normalization), 누락된 값 다루기, 특성 추출 등이 포함된다.

벡터화

  • 신경망에서 모든 입력과 타깃은 부동 소수 데이터로 이루어진 텐서여야 한다. (때로는 정수로 된 텐서)
  • 사운드, 이미지, 텍스트 등 처리해야 할 것이 무엇이든 먼저 텐서로 변환해야 하는데, 이 단계를 데이터 벡터화(data vectorization)이라고 한다.

값 정규화

  • 숫자 이미지 분류 예에서 이미지 데이터를 그레이스케일 인코딩인 0-255 사이의 정수로 인코딩했는데, 이 데이터를 네트워크에 주입하기 전에 float32 타입으로 변경하고 255로 나누어서 0-1사이의 부동 소수 값으로 만들었다.
  • 주택 가격을 예측할 때는 특성들의 범위가 제각각이었기 때문에, 이 데이터를 네트워크에 주입하기 전에 각 특성을 독립적으로 정규화하여 평균이 0이고 표준 편차가 1이 되도록 만들었다.
  • 일반적으로 비교적 큰 값이나 균일하지 않은 데이터를 신경망에 주입하는 것은 위험한데, 업데이트할 그래디언트가 커져 네트워크가 수렴하는 것을 방해하기 때문이다.
  • 네트워크가 쉽게 학습시키려면 데이터가 다음 특징을 따라야 한다.
    • 작은 값을 취한다. 일반적으로 대부분의 값이 0-1 사이여야 한다.
      • (0-1사이의 값은 계속 곱하면 수렴하는 이유도 있을 듯. 1이 넘으면 발산할 가능성이 높다)
    • 균일해야 한다. 즉 모든 특성이 대체로 비슷한 범위를 가져야 한다.
  • 추가적으로 다음에 나오는 엄격한 정규화 방법은 꼭 필수적이지는 않지만 자주 사용되고 도움이 될 수 있다.
    • 각 특성별로 평균이 0이 되도록 정규화한다.
    • 각 특성별로 표준 편차가 1이 되도록 정규화한다.
    • (이말은 데이터가 scale-free해서 평균과 표준편차 정규화가 안되는 데이터는 다룰 수 없다는 뜻인가?)
  • 넘파이 배열에서는 다음처럼 간단히 할 수 있다.
x -= x.mean(axis=0)
x /= x.std(axis=0)

누락된 값 다루기

  • 이따금 데이터에 값이 누락된 경우가 있다.
    • 예컨대 주택 가격 예측 문제에서 첫 번째 특성은 1인당 범죄율인데, 이 특성이 모든 샘플에 들어 있지 않은 경우 어떻게 될까? 훈련 데이터나 테스트 데이터에 누락된 값이 포함된다.
    • 일반적으로 신경망에서 0이 사전에 정의된 의미 있는 값이 아니라면 누락된 값을 0으로 입력해도 괜찮다. 네트워크가 0이 누락된 데이터를 의미한다는 것을 학습하면 이 값을 무시하기 싲가할 것이다.
  • 테스트 데이터에 누락된 값이 포함될 가능성이 있다고 가정하자. 하지만 네트워크가 누락된 값이 없는 데이터에서 훈련되었다면 이 네트워크는 누락된 값을 무시하는 법을 알지 못한다.
    • 이런 경우에는 누락된 값이 있는 훈련 샘플을 고의적으로 만들어야 한다. 훈련 샘플의 일부를 여러벌 복사해서 테스트 데이터에서 빠질 것 같은 특성을 제거한다.

특성 공학

  • 특성 공학은 데이터와 머신 러닝 알고리즘(여기서는 신경망)에 관한 지식을 사용하는 단계이다.
    • 모델에 데이터를 주입하기 전에 (학습이 아닌) 하드코딩된 변환을 적용하여 알고리즘이 더 잘 수행되도록 만들어준다.
    • 많은 경우에 머신 러닝 모델이 임의의 데이터로부터 완벽한 학습을 한다고 기대하기는 어렵다. 모델이 수월하게 작업할 수 있는 어떤 방식으로 데이터가 표현될 필요가 있다.
  • 예컨대 시계 이미지를 입력으로 받고 하루의 시간을 출력하는 모델을 개발한다고 가정하자. 
    • 이미지의 원본 픽셀을 입력으로 사용한다면 어려운 머신 러닝 문제가될 것이다.

  • 고수준에서 이 문제를 이해하고 있다면(우리는 시계에서 시간을 읽는 방법을 알고 있다) 머신 러닝 알고리즘을 위해 훨씬 더 좋은 입력 특성을 만들 수 있다.
    • 예컨대 시계 바늘의 검은색 픽셀을 따라 각 바늘 끝의 (x, y) 좌표를 출력하는 간단한 파이썬 스크립트를 만든다. 그 다음 머신 러닝 알고리즘이 이 좌표와 적절한 시각을 연결하도록 쉽게 학습될 수 있다.
    • 이보다 더 좋은 특성을 만들 수도 있는데, 좌표를 바꾸어 (x, y) 포인트를 이미지 중심에 대한 극좌표로 나타낼 수 있다. 이제 각 시계 바늘의 각도가 입력이 된다.
    • 이렇게 특성을 준비하면 문제가 너무 쉬워져서 머신 러닝이 전혀 필요하지 않다. 간단한 반올림 연산과 딕셔너리 참조만으로 하루의 시간을 추정하기 충분하다.
  • 이것이 특성 공학의 핵심이다. 특성을 더 간단한 방식으로 표현하여 문제를 쉽게 만든다. 일반적으로 해당 문제를 아주 잘 이해하고 있어야 한다.
  • 딥러닝 이전에는 특성 공학이 아주 중요했다. 전통적인 얕은 학습 방법의 알고리즘들은 스스로 유용한 특성을 학습할 만큼 충분히 넓은 가설 공간을 가지고 있지 않다. 알고리즘에 데이터를 표현하는 방식에 성공 여부가 달려있다.
    • 예컨대 합성곱 신경망이 MNIST 숫자 이미지 분류 문제를 해결하기 전까지 전형적인 해결책은 하드코딩된 특성을 사용하는 것이었다.
    • 숫자 이미지에 있는 동신원의 수, 이미지에 있는 숫자의 높이, 픽셀 값의 히스토그램(histogram) 등이다.
  • 다행히 최근 딥러닝은 대부분 특성 공학이 필요하지 않다. 신경망이 자동으로 원본 데이터에서 유용한 특성을 추출할 수 있기 때문이다. 그렇다면 심층 신경망을 사용할 때 특성 공학에 대해 신경 쓰지 않아도 될것인가 하면 그것은 두 가지 이유로 그렇지 않다.
    • 좋은 특성은 적은 자원을 사용하여 문제를 더 멋지게 풀어낼 수 있다. 예컨대 시계 바늘을 읽는 문제에 합성곱 신경망을 사용하는 것은 어울리지 않다.
    • 좋은 특성은 더 적은 데이터로 문제를 풀 수 있다. 딥러닝 모델이 스스로 특성을 학습하는 능력은 가용한 훈련 데이터가 많을 때 발휘된다. 샘플의 개수가 적다면 특성에 있는 정보가 매우 중요해진다.

과대적합과 과소적합

  • 머신 러닝의 근본적인 이슈는 최적화와 일반화 사이의 줄다리기이다. 최적화(optimization)는 가능한 훈련 데이터에서 최고의 성능을 얻으려고 모델을 조정하는 과정이고, 일반화(generalization)은 훈련된 모델이 이전에 본 적 없는 데이터에서 얼마나 잘 수행되는지를 의미한다.
  • 훈련 초기에 최적화와 일반화는 상호 연관되어 있다. 훈련 데이터의 손실이 낮아질수록 테스트 데이터의 손실도 낮아진다. 이런 상황이 발생할 때 모델이 과소적합(underfitting)되었다고 말한다.
    • 이때는 모델의 성능이 계속 발전될 여지가 있다. 즉, 네트워크가 훈련 데이터에 있는 관련 특성을 모두 학습하지 못한 상태다.
  • 훈련 데이터에 여러 번 반복 학습하고 나면 어느 시점부터 일반화 성능이 더 높아지지 않고, 검증 세트의 성능이 멈추고 감소되기 시작한다. 즉 모델이 과대적합되기 시작한다.
    • 이는 훈련 데이터에 특화된 패턴을 학습하기 시작했다는 의미이다. 이 패턴은 새로운 데이터와 관련성이 적기 때문에 잘못된 판단을 하게 만든다.
  • 모델이 관련성이 없고 좋지 못한 패턴을 훈련 데이터에서 학습하지 못하도록 하려면 가장 좋은 방법은 더 많은 훈련 데이터를 모으는 것이다.
    • 더 많은 데이터에서 훈련된 모델은 자연히 일반화 성능이 더 뛰어나다.
    • 데이터를 더 모으는 것이 불가능할 때 차선책은 모델이 수용할 수 있는 정보의 양을 조절하거나 저장할 수 있는 정보에 제약을 가하는 것이다.
    • 네트워크가 적은 수의 패턴만 기억할 수 있다면 최적화 과정에서 가장 중요한 패턴에 집중하게 될 것이다. 이런 패턴은 더 나은 일반화 성능을 제공할 수 있다.
  • 이런 식으로 과대적합을 피하는 처리 과정을 규제(regularization)이라고 한다.

네트워크 크기 축소

  • 과대적합을 막는 가장 단순한 방법은 모델의 크기, 즉 모델에 있는 학습 파라미터의 수를 줄이는 것이다.
    • 파라미터의 수는 층의 수와 각 층의 유닛 수에 의해 결정된다. 딥러닝에서 모델에 있는 학습 파라미터의 수를 종종 모델의 용량(capacity)라고 한다.
    • 당연하게도 파라미터가 많은 모델이 기억 용량이 더 많다. 훈련 샘플과 타깃 사이를 딕셔너리 같은 일대일 매핑으로 완벽하게 학습할 수도 있다. 이런 매핑은 일반화 능력이 없다.
    • 항상 유념해야 할 것은 딥러닝 모델은 훈련 데이터에 잘 맞추려는 경향이 있다는 점이다. 하지만 진짜 문제는 최적화가 아니고 일반화이다.
  • 다른 한편으로 네트워크가 기억 용량에 제한이 있다면 이런 매핑을 쉽게 학습하지 못할 것이다. 따라서 손실을 최소화하기 위해 타깃에 대한 예측 성능을 가진 압축된 표현을 학습해야 한다. 정확히 이런 표현이 우리 관심 대상이다.
    • 동시에 기억해야 할 것은 과소적합되지 않도록 충분한 파라미터를 가진 모델을 사용해야 한다는 점이다. 모델의 기억 용량이 부족해서는 안 된다.
  • 너무 많은 용량과 충분하지 않은 용량 사이의 절충점을 찾아야 한다. 하지만 알맞은 층의 수나 각 층의 유닛 수를 결정할 수 있는 마법 같은 공식은 없다.
    • 데이터에 알맞은 모델 크기를 찾으려면 각기 다른 구조를 평가해 보아야 한다.
    • 적절한 모델 크기를 찾는 일반적인 작업 흐름은 비교적 적은 수의 층과 파라미터로 싲가한다. 그 다음 검증 손실이 감소되기 시작할 때까지 층이나 유닛의 수를 늘리는 것이다.
  • 영화 리뷰 분류 모델을 예로 들어보자. 원래 네트워크는 다음과 같다.
from keras import models
from keras import layers

model = models.Sequential()
model.add(layers.Dense(16, activation='relu', input_shape=(10000,)))
model.add(layers.Dense(16, activation='relu'))
model.add(layers.Dense(1, activation='sigmoid'))
  • 더 작은 네트워크로 바꾸어보자
model = models.Sequential()
model.add(layers.Dense(16, activation='relu', input_shape=(10000,)))
model.add(layers.Dense(16, activation='relu'))
model.add(layers.Dense(1, activation='sigmoid'))
  • 아래 그림은 원본 네트워크와 축소된 네트워크의 검증 손실을 비교한 것이다. 점으로 표현한 것이 작은 네트워크고 덧셈 기호가 원래 네트워크이다. (검증 손실이 작은 것이 좋은 모델이다)

  • 그림에서 볼 수 있듯이 작은 네트워크가 기본 네트워크보다 더 나중에 과대적합되기 시작했다. 과대적합이 시작되었을 때 성능이 더 천천히 감소되었다.
  • 이번에는 훨씬 더 많은 용량을 가진 네트워크를 비교해 보자.
model = models.Sequential()
model.add(layers.Dense(1024, activation='relu', input_shape=(10000,)))
model.add(layers.Dense(1024, activation='relu'))
model.add(layers.Dense(1, activation='sigmoid'))
  • 아래 그림은 더 큰 네트워크가 기본 네트워크에 비해 얼마나 차이 나는지 보여준다.

  • 욜량이 큰 네트워크는 첫 번째 에포크 이후 거의 바로 과대적합이 시작되어 갈수록 더 심해진다. 검증 손실도 매우 불안정해진다.
  • 한편 아래 그림은 두 네트워크의 훈련 손실을 보여준다. 여기서 볼 수 있듯이 용량이 큰 네트워크는 훈련 손실이 매우 빠르게 0에 가까워진다.
    • 용량이 많은 네트워크일수록 더 빠르게 훈련 데이터를 모델링할 수 있다.(결국 훈련 손실이 낮아진다)
    • 하지만 더욱 과대적합에 민감해진다. (결국 훈련과 검증 손실 사이에 큰 차이가 발생한다.)

가중치 규제 추가

  • 오캄의 면도날(Occam’s razor)에 따르면 더 적은 가정이 필요한 간단한 설명이 옳다. 이는 신경망으로 학습되는 모델에도 적용된다.
    • 어떤 훈련 데이터와 네트워크 구조가 주어졌을 때 데이터를 설명할 수 있는 가중치 값의 집합은 여러 개이다. 간단한 모델이 복잡한 모델보다 덜 과대적합될 가능성이 높다.
  • 여기에서 간단한 모델은 파라미터 값 분포의 엔트로피가 작은 모델이다. 그러므로 과대적합을 완화하기 위한 일반적인 방법은 네트워크 복잡도에 제한을 두어 가중치가 작은 값을 가지도록 강제하는 것이다.
    • 가중치 값의 분포가 더 균일하게 된다. 이를 가중치 규제(weight regularization)이라고 하며, 네트워크의 손실 함수에 큰 가중치에 연관된 비용을 추가한다. 두가지 형태의 비용이 있다.
    • L1 규제: 가중치의 절댓값에 비례하는 비용이 추가된다. (가중치의 L1 노름(norm))
    •  L2 규제: 가중치의 제곱에 비례하는 비용이 추가된다. (가중치의 L2 노름). L2 규제는 신경망에서 가중치 감쇠(weight decay)라고 한다. 가중치 감쇠는 수학적으로 L2 규제와 동일하다.
  • 케라스에서 가중치 규제 객체를 층의 키워드 매개변수로 전달해서 가중치 규제를 추가할 수 있다. 다음의 예를 보자.
from keras import regularizers

model = models.Sequential()
model.add(layers.Dense(16, kernel_regularizer=regularizers.l2(0.001), activation='relu', input_shape=(10000,)))
model.add(layers.Dense(16, kernel_regularizer=regularizers.l2(0.001), activation='relu'))
model.add(layers.Dense(1, activation='sigmoid'))
  • l2(0.001)는 가중치 행렬의 모든 원소를 제곱하고 0.001을 곱하여 네트워크 전체 손실에 더해진다는 의미이다. 이 페널티(penalty) 항은 훈련할 때만 추가된다. 이 네트워크의 손실은 테스트보다 훈련할 때 더 높을 것이다.
  • 아래 그림은 L2 규제 페널티의 효과를 보여준다. 여기서 볼 수 있듯이 두 모델이 동일한 파라미터 수를 가지고 있더라도 L2 규제를 사용한 모델이 기본 모델보다 훨씬 더 과대적합에 잘 견디고 있다.

  • 케라스에서는 L2 규제 대신 다음 가중치 규제 중 하나를 사용할 수 있다.
from keras import regularizers

regularizers.l1(0.001) -- L1 규제
regularizers.l1_l2(l1=0.001, l2=0.001) -- L1과 L2 규제 병행

드롭아웃 추가

  • 드롭아웃(dropout)은 토론토 대학의 제프리 힌튼과 그의 학생들이 개발한 것으로 신경망을 위해 사용되는 규제 기법 중 가장 효과적이고 널리 사용되는 방법 중 하나이다.
    • 네트워크 층에 드롭아웃을 적용하면 훈련하는 동안 무작위로 층의 일부 출력 특성을 제외시킨다(0으로 만든다)
  • 한 층이 정상적으로 훈련하는 동안에는 어떤 입력 샘플에 대해 [0.2, 0.5, 1.3, 0.8, 1.1] 벡터를 출력한다고 가정해보자. 드롭아웃을 적용하면 이 벡터의 일부가 무작위로 0으로 바뀐다.
    • 드롭아웃의 비율은 0이 될 특성의 비율이다. 보통 0.2-0.5 사이로 지정된다.
    • 테스트 단계에서는 어떤 유닛도 드롭아웃되지 않는다. 그 대신 층의 출력을 드롭아웃 비율에 비례해서 줄여준다. 훈련할 때보다 더 많은 유닛이 활성화 되기 때문이다.
  • 크기가 (batch_size, features)인 한 층의 출력을 담고 있는 넘파이 행렬을 생각해보자. 훈련할 때는 이 행렬 값의 일부가 랜덤하게 0이 된다.
# 훈련할 때 유닛의 출력 중 50%를 버린다.
layer_output += np.random.randint(0, high=2, size=layer_output.shape)
  • 테스트할 때는 드롭아웃 비율로 출력을 낮추어 주어야 한다. 여기서 0.5배만큼 스케일을 조정했다. (앞에서 절반의 유닛을 드롭아웃 했으므로)
layer_output *= 0.5  --테스트 단계
  • 훈련 단계에 이 두 연산을 포함시켜 테스트 단계에는 출력을 그대로 두도록 구현할 수 있다. 실제로 종종 이런 방식으로 구현한다.
layer_output += np.random.randint(0, high=2, size=layer_output.shape) --훈련 단계
layer_output /= 0.5 --여기서는 스케일을 낮추는 대신 높인다.

  • 이 기법이 이상하고 무계획적으로 보일 수 있다. 왜 드롭아웃이 과대적합을 줄이는데 도움이 될까?
    • 힌튼은 은행에서 사용하는 부정 방비 메커니즘에서 착안했다고 한다. 
    • “은행에 갔을 때 행원들이 계속 바뀌길래 왜 그런지 물었더니, ‘자신들도 이유는 모르지만 자주 업무가 바뀐다’고 했다. 나는 은행에서 부정 행위를 하려면 직원들 사이의 유대가 필요하기 때문이라고 판단했다. 각 샘플에 대해 뉴런의 일부를 무작위하게 제거하면 뉴런의 부정한 협업을 방지하고 결국 과대적합을 감소시킨다는 것을 깨달았다.”
    • 핵심 아이디어는 층의 출력 값에 노이즈를 추가하여 중요하지 않은 우연한 패턴(힌튼이 말한 부정한 협업)을 깨드리는 것이다.
    • 노이즈가 없다면 네트워크가 이 패턴을 기억하기 시작할 것이다.
  • 케라스에서는 층의 바로 뒤에 Dropout 층을 추가하여 네트워크에 드롭아웃을 적용할 수 있다.
model.add(layers.Dropout(0.5))
  • IMDB 네트워크에 2개의 Dropout 층을 추가하고 과대적합을 얼마나 줄여주는지 확인해 보자.
model = models.Sequential()
model.add(layers.Dense(1024, activation='relu', input_shape=(10000,)))
model.add(layers.Dropout(0.5))
model.add(layers.Dense(1024, activation='relu'))
model.add(layers.Dropout(0.5))
model.add(layers.Dense(1, activation='sigmoid'))
  • 아래는 결과 그래프이다. 여기서도 기본 네트워크보다 확실히 더 향상된 것을 볼 수 있다.

  • 정리하면 신경망에서 과대적합을 방지하기 위해 가장 널리 사용되는 방법은 다음과 같다.
    • 훈련 데이터를 더 모은다.
    • 네트워크의 용량을 감소시킨다.
    • 가중치 규제를 추가한다.
    • 드롭아웃을 추가한다.

보편적인 머신 러닝 작업 흐름

문제 정의와 데이터셋 수집

  • 먼저 주어진 문제를 정의해야 한다.
    • 입력데이터는 무엇이고 어떤 것을 예측하려고 하고 가용한 훈련 데이터가 있어야 하고 등
    • 당면한 문제가 어떤 종류인지, 이진분류인지 다중 분류인지 스칼라 회귀인지 벡터 회귀인지, 다중 레이블 다중 분류인지, 군집, 생성 또는 강화 학습 문제인지
  • 입력과 출력이 무엇인지와 어떤 데이터를 사용할 것인지 알기 전까지는 다음 단계로 넘어갈 수 없다. 이 단계에서 가설을 세워야 한다.
    • 주어진 입력으로 출력을 예측할 수 있다고 가설을 세운다.
    • 가용한 데이터에 입력과 출력 사이의 관계를 학습하는데 충분한 정보가 있다고 가설을 세운다.
  • 모델이 작동하기 전까지 이는 가설에 불과하기 때문에 검증될지 아닐지 기다려 보아야 한다. 모든 문제가 해결되지는 않는다.
  • 풀기 어려운 종류의 문제는 시간에 따라 변하는 문제(nonstationary problem)이다. 
    • 옷을 위한 추천 엔진을 예로 들어보자. 8월 데이터로 훈련하고 겨울에 이 엔진을 사용하려고 하면 결과가 좋게 나올 수가 없다.
  • 머신 러닝은 훈련 데이터에 있는 패턴을 기억하기 위해서만 사용한다는 것을 기억하라. 이미 보았던 것만 인식할 수 있다. 미래를 예측하기 위해 과거 데이터에서 훈련한 머신 러닝을 사용하는 것은 미래가 과거처럼 움직인다고 가정한 것이다. 사실 대부분 그렇지 않다.

성공 지표 선택

  • 어떤 것을 제어하려면 관측할 수 있어야 한다. 성공 하기 위해서는 성공은 무엇인가를 정의해야 한다. 
    • 정확도, 정밀도, 재현율, 재방문율 등 성공의 지표가 모델이 최적화할 손실 함수를 선택하는 기준이 된다. 비즈니스 성공처럼 고수준의 목표와 직접적으로 연결되어 있어야 한다.
  • 클래스 분포가 균일한 분류 문제에서는 정확도와 ROC AUC가 일반적인 지표이다.
    • 클래스 분포가 균일하지 않은 문제에서는 정밀도와 재현율을 사용할 수 있다.
    • 랭킹 문제나 다중 레이블 문제에는 평균 정밀도를 사용할 수 있다.
    • 성공을 측정하기 위해 자신만의 지표를 정의하는 일은 일반적이지 않다.

평가 방법 선택

  • 목표를 정했다면 현재의 진척 상황을 평가할 방법을 정의해야 한다. 
    • 홀드아웃 검증 세트 분리: 데이터가 풍부할 때 사용한다.
    • K-겹 교차 검증: 홀드아웃 검증을 사용하기에 샘플의 수가 너무 적을 때 사용한다.
    • 반복 K-겹 교차 검증: 데이터가 적고 매우 정확한 모델 평가가 필요할 때 사용한다.
  • 위 3가지 중 하나를 선택하면 된다. 대부분의 경우 첫 번째로 충분할 것이다.

데이터 준비

  • 무엇을 훈련할지와 무엇을 최적화할지, 그리고 어떻게 평가할지를 정했다면 거의 모델을 훈련 시킬 준비가 된 것이다. 하지만 머신 러닝 모델에 주입할 데이터를 구성해야 한다. 여기에서는 이 머신 로닝 모델을 심층 신경망이라고 가정한다.
    • 데이터는 텐서로 구성된다.
    • 이 텐서에 있는 값은 일반적으로 작은 값으로 스케일 조정되어 있다. 예컨대 [-1, 1] 이나 [0, 1] 범위이다.
    • 특성마다 범위가 다르면 (여러 종류의 값으로 이루어진 데이터라면) 정규화해야 한다.
    • 특성 공학을 수행할 수 있다. 특히 데이터가 적을 때이다.
  • 입력 데이터와 타깃 데이터의 텐서가 준비되면 모델을 훈련시킬 수 있다.

기본보다 나은 모델 훈련하기

  • 이 단계의 목표는 통계적 검정력(statistical power)을 달성하는 것이다. 즉 아주 단순한 모델보다 나은 수준의 작은 모델을 개발한다.
    • MNIST 숫자 이미지 분류 예에서는 0.1보다 높은 정확도를 내는 모델이 통계적 검정력을 가졌다고 말할 수 있다.
    • IMDB 에서는 0.5보다 높은 정확도를 갖는 것이다.
  • 통계적 검정력을 달성하는 것이 항상 가능하지는 않다. 여러 개의 타당성이 있는 네트워크 구조를 시도해 보고 무작위로 예측하는 모델보다 낫지 않다면 입력 데이터에 존재하지 않는 것을 얻으려고 한다는 신호이다. 2개의 가설이 있다는 것을 기억하라.
    • 주어진 입력으로 출력을 예측할 수 있다고 가설을 세운다.
    • 가용한 데이터에 입력과 출력 사이의 관계를 학습하는데 충분한 정보가 있다고 가설을 세운다.
  • 이 가설이 잘못된 것일 수 있다. 이때는 기획부터 다시 해야 한다.
  • 일이 잘 진행된다고 가정하면 첫 번째 모델을 만들기 위해 세 가지 중요한 선택을 해야 한다.
    • 마지막 층의 활성화 함수: 네트워크의 출력에 필요한 제한을 가한다. 예컨대 IMDB 분류 예는 마지막 층에 시그모이드 함수를 사용한다. 회귀 예에서는 마지막 층에 활성화 함수를 사용하지 않는다.
    • 손실 함수: 풀려고 하는 문제의 종류에 적합해야 한다. 예컨대 IMDB 예제는 binary_crossentropy를 사용하고, 회귀 예제는 mse를 사용하는 식이다.
    • 최적화 설정: 어떤 옵티마이저를 사용하는지? 학습률은 얼마인지? 대부분의 경우 rmsprop과 기본 학습률을 사용하는 것이 무난하다.
  • 손실 함수의 선택에 대해서 언급할 것은 주어진 문제의 성공 지표를 직접 최적화하는 것이 항상 가능하지는 않다는 점이다. 때로는 이 지표를 손실 함수로 바꿀 수 있는 방법이 없다.
    • 무엇보다도 손실 함수는 주어진 미니 배치 데이터에서 계산 가능해야 하고,(이상적으로 손실 함수는 하나의 데이터 포인트에서 계산 가능해야 한다) 미분 가능해야 한다.(그렇지 않으면 역전파 알고리즘을 사용하여 네트워크를 훈련시킬 수 없다.)
    • 예컨대 널리 사용되는 분류 지표인 ROC AUC는 직접 최적화될 수 없다. 그래서 분류 작업에는 크로스엔트로피처럼 ROC AUC를 대신할 지표를 최적화하는 것이 보통이다. 일반적으로 크로스엔트로피가 낮을수록 ROC AUC가 높다고 기대할 수 있다.
  • 다음 표는 자주 등장하는 문제 유형에 따라 선택할 수 있는 마지막 츠으이 활성화 함수와 손실 함수이다.
문제 유형 마지막 층의 활성화 함수 손실 함수
이진 분류 시그모이드 binary_crossentropy
단일 레이블 다중 분류 소프트맥스 categorical_crossentropy
다중 레이블 다중 분류 시그모이드 binary_crossentropy
임의 값에 대한 회귀 없음 mse
0과 1 사이 값에 대한 회귀 시그모이드 mse 또는 binary_crossentropy

몸집 키우기: 과대적합 모델 구축

  • 통계적 검정력을 가진 모델을 얻었다면 이제 이 모델이 충분히 성능을 내는지 질문해 보아야 한다. 주어진 문제를 적절히 모델링하기에 충분한 층과 파라미터가 있는가?
    • 예컨대 2개의 유닛을 가진 하나의 은닉 층으로 구성된 네트워크가 있다고 가정하자. 이 네트워크가 MNIST 데이터셋에서 통계적 검정력을 가질 수 있지만 문제를 잘 해결하기에는 충분하지 않을 것이다.
    • 머신러닝은 최적화와 일반화 사이의 줄다리기라는 점을 기억하라. 과소적합과 과대적합 사이, 즉 과소용량과 과대용량의 경계에 적절히 위치한 모델이 이상적이다.
    • 이 경계가 어디에 위치하는지 찾기 위해서는 먼저 지나쳐 보아야 한다.
  • 얼마나 큰 모델을 만들어야 하는지 알기 위해서는 과대적합된 모델을 만들어야 한다. 이는 아주 쉽다.
    • 층을 추가한다.
    • 층의 크기를 키운다.
    • 더 많은 에포크 동안 훈련한다.
  • 관심 대상인 훈련과 검증 지표는 물론 항상 훈련 손실과 검증 손실을 모니터링하라. 검증 데이터에서 모델 성능이 감소하기 시작했을 때 과대적합에 도달한 것이다.
  • 다음 단계에서 규제와 모델 튜닝을 싲가하여 과소적합도 아니고 과대적합도 아닌 이상적인 모델에 가능한 가깝도록 만든다.

모델 규제와 하이퍼파라미터 튜닝

  • 이 단계가 대부분의 시간을 차지한다. 반복적으로 모델을 수정하고 훈련하고 검증 데이터에서 평가한다 (이때 테스트 데이터를 사용하지 않는다). 그리고 다시 수정하고 가능한 좋은 모델을 얻을 때까지 반복한다. 적용해 볼 것은 다음과 같다.
    • 드롭아웃을 추가한다.
    • 층을 추가하거나 제거해서 다른 구조를 시도해 본다.
    • L1이나 L2 또는 두 가지 모두 추가한다.
    • 최적의 설정을 찾기 위해 하이퍼파라미터를 바꾸어 시도해 본다. (층의 유닛 수나 옵티마이저의 학습률 등)
    • 선택적으로 특성 공학을 시도해 본다. 새로운 특성을 추가하거나 유용하지 않을 것 같은 특성을 제거한다.
  • 다음 사항을 유념하라. 검증 과정에서 얻은 피드백을 사용하여 모델을 튜닝할 때마다 검증 과정에 대한 정보를 모델에 누설하고 있다는 것이다.
    • 몇 번만 반복하는 것은 큰 문제가 되지 않지만, 많이 반복하게 되면 모델이 검증 데이터에서 전혀 훈련하지 않았음에도 결국 모델이 검증 과정에 과댖거합될 것이다. 이는 검증 과정의 신뢰도를 감소시킨다.
  • 만족할 만한 모델 설정을 얻었다면 가용한 모든 데이터(훈련 데이터와 검증 데이터)를 사용해서 제품에 투입할 최종 모델을 훈련 시킨다. 그리고 마지막에 딱 한 번 테스트 세트에서 평가한다.
    • 테스트 세트의 성능이 검증 데이터에서 측정한 것보다 많이 나쁘다면, 검증 과정에 전혀 신뢰성이 없거나 모델의 하이퍼파라미터를 튜닝하는 동안 검증 데이터에 과대적합된 것이다.
    • 이런 경우에는 좀 더 신뢰할만한 평가 방법으로 바꾸는 것이 좋다. (반복 K-겹 교차 검증)

요약

  • 주어진 문제와 훈련할 데이터를 정의한다. 이 데이터를 수집하고 필요하면 레이블을 태깅한다.
  • 성공을 어떻게 측정할지 선택하라. 검증 데이터에서 모니터링할 지표는 무엇인가?
  • 평가 방법을 결정하라. 홀드아웃 검증이나 K-겹 교차 검증인가? 검증에 사용해야 할 데이터의 양은 얼마나 되는가?
  • 단순한 랜덤 선택 모델보다 나은 통계적 검정력이 있는 첫 번째 모델을 만든다.
  • 과대적합된 모델을 만든다.
  • 검증 데이터의 성능에 기초하여 모델에 규제를 적용하고 하이퍼파라미터를 튜닝한다. 머신 러닝 연구의 대부분은 이 단계에 집중된다.

케라스 창시자에게 배우는 딥러닝/ 신경망 시작하기

신경망의 구조

  • 신경망 훈련에는 다음 요소들이 관련되어 있다.
    • 네트워크(또는 모델)를 구성하는 층
    • 입력 데이터와 그에 상응하는 타깃
    • 학습에 사용할 피드백 신호를 정의하는 손실 함수
    • 학습 진행 방식을 결정하는 옵티마이저
  • 이들 간의 상호 작용은 아래 그림에 나타냈다.
    • 연속된 층으로 구성된 네트워크가 입력 데이터를 예측으로 매핑한다.
    • 손실 함수는 예측과 타깃을 비교하여 네트워크의 예측이 기댓값에 얼마나 잘 맞는지를 측정하는 손실 값을 만든다.
    • 옵티마이저는 손실 값을 사용하여 네트워크 가중치를 업데이트 한다.

층: 딥러닝의 구성 단위

  • 신경망의 핵심적인 데이터 구조는 층이다. 층은 하나 이상의 텐서를 입력으로 받아 하나 이상의 텐서를 출력하는 데이터 처리 모듈이다.
    • 어떤 종류의 층은 상태가 없지만 대부분의 경우 가중치라는 층의 상태를 가진다.
    • 가중치는 확률적 경사 하강법에 의해 학습되는 하나 이상의 텐서이며 여기에 네트워크가 학습한 지식이 담겨 있다.
  • 층마다 적절한 텐서 포맷과 데이터 처리 방식이 다르다.
    • 예컨대 (samples, features) 크기의 2D 텐서가 저장된 간단한 벡터 데이터는 완전 연결 층(fully connected layer)이나 밀집 층(dense layer)라고도 불리는 밀집 연결 층(densely connected layer)에 의해 처리되는 경우가 많다. (케라스에서는 Dense 클래스이다)
    • (samples, timesteps, features) 크기의 3D 텐서로 저장된 시퀀스 데이터는 보통 LSTM 같은 순환 층(recurrent layer)에 의해 처리된다.
    • 4D 텐서로 저장되어 있는 이미지 데이터는 일반적으로 2D 합성곱 층(convolution layer)에 의해 처리 된다. (Conv2D 클래스)
  • 층을 딥러닝의 레고 블록처럼 생각할 수 있다. 이런 비유는 케라스 같은 프레임워크 때문에 생겼는데, 케라스에서는 호환 가능한 층들을 엮어 데이터 변환 파이프라인(pipeline)을 구성함으로써 딥러닝 모델을 만든다.
    • 여기에서 층 호환성(layer compatibility)은 각 층이 특정 크기의 입력 텐서만 받고 특정 크기의 출력 텐서를 반환한다는 사실을 말한다.
  • 다음 예를 살펴 보자.
from keras import layers
layer = layers.Dense(32, input_shape=(784,)) --32개의 유닛으로 밀집된 층
  • 첫 번째 차원이 784인 2D 텐서만 입력으로 받는 층을 만들었다. (배치 차원인 0번째 축은 지정하지 않기 때문에 어떤 배치 크기도 입력으로 받을 수 있다.)
    • 이 층은 첫 번째 차원 크기가 32로 변환된 텐서를 출력할 것이다.
    • 따라서 이 층에는 32차원의 벡터를 입력으로 받는 하위 층이 연결되어야 한다. 케라스에서는 모델에 추가된 층을 자동으로 상위 층의 크기에 맞추어주기 때문에 호환성을 걱정하지 않아도 된다.
  • 예컨대 다음과 같이 작성 했다고 가정하자.
from keras import models
from keras import layers

model = models.Sequential()
model.add(layers.Dense(32, input_shape=(784,)))
model.add(layers.Dense(10))
  • 두 번째 층에는 input_shape 매개변수를 지정하지 않았는데, 이러면 앞선 층의 출력 크기를 입력 크기로 자동으로 채택한다.

모델: 층의 네트워크

  • 딥러닝 모델은 층으로 만든 비순환 유향 그래프(Directed Acyclic Graph, DAG)이다.
    • 가장 일반적인 예가 하나의 입력을 하나의 출력으로 매핑하는 층을 순서대로 쌓는 것이다.
  • 앞으로 공부하다 보면 아주 다양한 네트워크 구조를 보게 될 것이다. 자주 등장하는 것들은 다음과 같다.
    • 가지(branch)가 2개인 네트워크
    • 출력이 여러 개인 네트워크
    • 인셉션(Inception) 블록
  • 네트워크 구조는 가설 공간(hypothesis space)을 정의한다.
    • 1장에서 머신 러닝을 ‘가능성 있는 공간을 사전에 정의하고 피드백 신호의 도움을 받아 입력 데이터에 대한 유용한 변환을 찾는 것’으로 정의했는데, 네트워크 구조를 선택함으로써 가능성 있는 공간(가설 공간)을 입력 데이터에서 출력 데이터로 매핑하는 일련의 특정 텐서 연산으로 제한하게 된다.
    • 우리가 찾아야 할 것은 이런 텐서 연산에 포함도니 가중치 텐서의 좋은 값이다.
  • 딱 맞는 네트워크 구조를 찾아내는 것은 과학보다 예술에 가깝다. 신뢰할 만한 모범적인 사례와 원칙이 있지만 연습을 해야만 적절한 신경망을 설계할 수 있는 기술을 갖추게 될 것이다.
    • (여기서 과학은 이론적으로 구성 가능한 것을 의미하고, 예술은 훈련을 통해 향상 시키는 기예의 의미에 가깝다. 실제 Art에는 그런 의미가 포함 되어 있음. 예술 작품이 아니라)

손실 함수와 옵티마이저: 학습 과정을 조절하는 열쇠

  • 네트워크 구조를 정의하고 나면 두 가지를 더 선택해야 한다.
    • 손실 함수(loss function) (목적 함수(objective function)): 훈련하는 동안 최소화 될 값이다. 주어진 문제에 대한 성공 지표가 된다.
    • 옵티마이저(optimizer): 손실 함수를 기반으로 네트워크가 어떻게 업데이트될지 결정한다. 특정 종류의 확률적 경사 하강법(SGD)를 구현한다.
  • 여러 개의 출력을 내는 신경망은 여러 개의 손실 함수를 가질 수 있다. (출력당 하나씩).
    • 하지만 경사 하강법 과정은 하나의 스칼라 손실 값을 기준으로 한다. 따라서 손실이 여러 개인 네트워크에서는 모든 손실이 (평균을 내서) 하나의 스칼라 양으로 합쳐진다.
  • 문제에 맞는 올바른 목적 함수를 선택하는 것이 아주 중요하다. 네트워크가 손실을 최소화하기 위해 편법을 사용할 수 있기 때문이다.
    • 목적 함수가 주어진 문제의 성공과 전혀 관련이 없다면 원하지 않은 일을 수행하는 모델이 만들어질 것이다.
    • “모든 인류의 평균 행복 지수를 최대화하기” 같은 잘못된 목적 함수에서 SGD로 훈련된 멍청하지만 전지전능한 AI가 있다고 가정하자. 이 문제를 쉽게 해결하기 위해 이 AI는 몇 사람을 남기고 모든 인류를 죽여서 남은 사람들의 행복에 초점을 맞출 수 있다.
    • 우리가 만든 모든 신경망은 단지 손실 함수를 최소화하기만 한다는 것을 기억하라. 목적 함수를 현명하게 선택하지 않으면 원하지 않는 부수 효과가 발생할 것이다.
  • 다행히 분류, 회귀와 시퀀스 예측 같은 일반적인 문제에서는 올바른 손실 함수를 선택하는 간단한 지침이 있다.
    • 예컨대 2개의 클래스가 있는 분류 문제에는 이진 크로스엔트로피(binary crossentropy), 여러 개의 클래스가 있는 분류 문제에는 범주형 크로스엔트로피(categorical croessentropy), 회귀 문제에는 평균 제곱 오차, 시퀀스 학습 문제에는 CTC(Connection Temporal Classification) 등을 사용한다.
    • 완전히 새로운 연구를 할 때만 독자적인 목적 함수를 만들게 된다.

케라스 소개

  • 이 책에서는 코드 예제를 위해 케라스(https://keras.io)를 사용한다.
    • 케라스는 거의 모든 종류의 딥러닝 모델을 간편하게 만들고 훈련시킬 수 있는 파이썬을 위한 딥러닝 프레임워크이다.
    • 케라스는 MIT 라이선스를 따르고 있으므로 상업적인 프로젝트에도 자유롭게 사용할 수 있다.

케라스, 텐서플로, 씨아노, CNTK

  • 케라스는 딥러닝 모델을 만들기 위한 고수준의 구성 요소를 제공하는 모델 수준의 라이브러리이다. 텐서 조작이나 미분 같은 저수준의 연산을 다루지 않는다. 
    • 대신 케라스의 백엔드 엔진에서 제공하는 최적화되고 특화된 텐서 라이브러리를 사용한다.
    • 케라스는 하나의 텐서 라이브러리에 국한하여 구현되어 있지 않고 모듈 구조로 구성되어 있다.
    • 여러 가지 백엔드 엔진이 케라스와 매끄럽게 연동된다. 현재는 텐서플로, 씨아노, 마이크로소프트 코그니티브 툴킷(Microsoft Cognitive Toolkit, CNTK) 3개를 백엔드 엔진으로 사용할 수 있다.

  • 텐서플로, CNTK, 씨아노는 딥러닝을 위한 주요 플랫폼 중 하나이다.
    • 씨아노는 몬트리올 대학 MILA 연구소에서 개발했고, 텐서플로는 구글에서 개발했으며, CNTK는 마이크로소프트에서 개발했다.
    • 케라스로 작성한 모든 코드는 아무런 변경 없이 이런 백엔드 중 하나를 선택해서 실행시킬 수 있다.
    • 개발하는 중간에 하나의 백엔드가 특정 작업에 더 빠르다고 판단되면 언제든 백엔드를 바꿀 수 있다.
    • 가장 널리 사용되고 확장성이 뛰어나며 상용 제품에 쓸 수 있기 때문에 딥러닝 작업에 텐서플로 백엔드가 기본으로 권장된다.
  • 텐서플로(또는 씨아노, CNTK)를 사용하기 때문에 케라스는 CPU와 GPU에서 모두 작동될 수 있다.
    • CPU에서 실행될 때 텐서플로는 Eigen이라고 불리는 저수준 텐서 연산 라이브러리를 사용하고, GPU에서는 NVIDIA CUDA 심층 신경망 라이브러리(cuDNN)라고 불리는 고도로 최적화된 딥러닝 연산 라이브러리를 이용한다.

케라스를 사용한 개발: 빠르게 둘러보기

  • 이미 케라스 모델의 예로 MNIST 예제를 살펴보았다. 전형적인 케라스 작업 흐름은 이 예제와 비슷하다.
    1. 입력 텐서와 타깃 텐서로 이루어진 훈련 데이터를 정의한다.
    2. 입력과 타깃을 매핑하는 층으로 이루어진 네트워크(또는 모델)를 정의한다.
    3. 손실 함수, 옵티마이저, 모니터링하기 위한 측정 지표를 선택하여 학습 과정을 설정한다.
    4. 훈련 데이터에 대해 모델의 fit() 메서드를 반복적으로 호출한다.
  • 모델을 정의하는 방법은 두 가지인데, Sequential 클래스(가장 자주 사용하는 구조인 층을 순서대로 쌓아 올린 네트워크) 또는 함수형 API(완전히 임의의 구조를 만들 수 있는 비순환 유향 그래프)를 사용한다.
  • Sequential 클래스를 사용하여 정의한 2개의 층으로 된 모델을 다시 살펴보자.
from keras import models
from keras import layers

model = models.Sequential()
model.add(layers.Dense(32, activation='relu', input_shape=(784,)))
model.add(layers.Dense(10, activation='softmax'))
  • 같은 모델을 함수형 API를 사용해서 만들면 다음과 같다.
input_tensor = layers.Input(shape=(784,))
x = layers.Dense(32, activation='relu')(input_tensor)
output_tensor = layers.Dense(10, activation='softmax')(x)

model = models.Model(inputs=input_tensor, outputs=output_tensor)
  • 함수형 API를 사용하면 모델이 처리할 데이터 텐서를 만들고 마치 함수처럼 이 텐서에 층을 적용한다.
  • 모델 구조가 정의된 후에는 Sequential 모델을 사용했는지 함수형 API를 사용했는지 상관없이 이후 단계가 동일하다.
  • 컴파일 단계에서 학습 과정이 설정된다. 여기에서 모델이 사용할 옵티마이저와 손실 함수, 훈련 하는 동안 모니터링하기 위해 필요한 측정 지표를 지정한다.
  • 다음이 하나의 손실 함수를 사용하는 가장 흔한 경우의 예이다.
from keras import optimizers

model.compile(optimizer=optimizers.RMSprop(lr=0.001), loss='mse', metrics=['accuracy'])
  • 마지막으로 입력 데이터의 넘파이 배열을 모델의 fit() 메서드에 전달함으로써 학습 과정이 이루어진다.
    • 이는 사이킷런(Scikit-Learn)이나 몇몇 다른 머신 러닝 라이브러리에서 하는 방식과 비슷하다.
model.fit(input_tensor, target_tensor, batch_size=128, epochs=10)

딥러닝 컴퓨터 셋팅

  • 딥러닝 애플리케이션을 실행할 때는 NVIDIA GPU에서 실행하는 것을 권장한다.
  • 운영체제는 유닉스(Unix) 운영체제를 사용하는 것이 좋다.
    • 기술적으로 윈도우에서 케라스를 사용할 수 있지만 권장하지는 않는다.
    • 번거로울 수 있지만 우분투를 사용하면 장기적으로 시간이 절약되고 문제가 발생할 가능성이 적다.

주피터 노트북: 딥러닝 실험을 위한 최적의 방법

  • 주피터 노트북(Jupyter Notebook)은 데이터 과학과 머신 러닝 커뮤니티에서 폭넓게 사용된다.
    • 노트북은 주피터 노트북 애플리케이션(https://jupyter.org)으로 만든 파일이며 웹 브라우저에서 작성할 수 있다.
    • 작업 내용을 기술하기 위해 서식 있는 텍스트 포맷을 지원하며 파이썬 코드를 실행할 수 있는 기능도 있다.
    • 필수적이지는 않지만, 케라스를 배울 때 주피터 노트북을 사용할 것을 권장한다. 하지만 파이참(PyCharm) 같은 IDE에서 코드를 실행하거나 독립된 파이썬 스크립트를 실행할 수도 있다.

케라스 시작하기: 두 가지 방법

  • 케라스를 실행하려면 다음 두 방법 중 하나를 권장한다.
    • 공식 EC2 딥러닝 AMI(https://aws.amazon.com/amazon-ai/amis)를 사용해서 EC2에서 주피터 노트북으로 케라스 예제를 실행한다. 로컬 컴퓨터에 GPU가 없을 때 이 방법을 사용한다.
  • 로컬 유닉스 컴퓨터에 처음부터 모든 것을 설치한다. 그 다음 로컬 컴퓨터에서 주피터 노트북을 실행하든지 일반 파이썬 스크립트를 실행한다.
    • 이미 고사양 GPU 카드가 있을 때 이 방법을 사용한다.

클라우드에서 딥러닝 작업을 수행했을 때 장단점

  • (생략)

어떤 GPU 카드가 딥러닝에 최적일까?

  • (생략)

영화 리뷰 분류: 이진 분류 예제

IMDB 데이터셋

  • 인터넷 영화 데이터베이스(Internet Movie Database)로부터 가져온 양극단의 리뷰 5만 개로 이루어진 IMDB 데이터셋을 사용하겠다.
    • 이 데이터셋은 훈련 데이터 25,000개와 테스트 데이터 25,000개로 나뉘어 있고 각각 50/50으로 긍정/부정 리뷰가 구성되어 있다.
  • MNIST 데이터셋처럼 IMDB 데이터셋도 케라스에 포함되어 있다. 이 데이터는 전처리되어 있어 각 리뷰(단어 시퀀스)가 숫자 시퀀스로 변환되어 있다.
    • 여기서 각 숫자는 사전에 있는 고유한 단어를 나타낸다.
  • 다음 코드는 데이터셋을 로드한다.
from keras.datasets import imdb

(train_data, train_labels), (test_data, test_labels) = imdb.load_data(num_words=10000)
  • (이하 데이터 설명 생략)

데이터 준비

  • 신경망에 숫자 리스트를 주입할 수는 없다. 리스트를 텐서로 바꾸는 두 가지 방법이 있다.
    • 같은 길이가 되도록 리스트에 패딩(padding)을 추가하고 (samples, sequence_length) 크기의 정수 텐서로 변환한다. 그 다음 이 정수 텐서를 다룰 수 있는 층을 신경망의 첫 번째 층으로 사용한다(Embedding 층)
    • 리스트를 원-핫 인코딩(one-hot encoding)하여 0과 1의 벡터로 변환한다. 예컨대 시퀀스 [3, 5]를 인덱스 3과 5의 위치는 1이고 그 외는 모두 0인 10,000차원의 벡터로 각각 변환한다. 그 다음 부동 소수 벡터 데이터를 다룰 수 있는 Dense 층을 신경망의 첫 번째 층으로 사용한다.
  • 여기서는 두 번째 방식을 사용하고 이해를 돕기 위해 직접 데이터를 원-핫 벡터로 만들겠다.
import numpy as np

def vectorize_sequences(sequences, dimension=10000):
results = np.zeros((len(sequences), dimension)) #크기가 (len(sequences), dimension)이고 모든 원소가 0인 행렬을 만든다.
for i, sequence in enumerate(sequences):
results[i, sequence] = 1. #results[i]에서 특정 인덱스의 위치를 1로 만든다.
return results

x_train = vectorize_sequences(train_data) #훈련데이터를 벡터로 변환한다
x_test = vectorize_sequences(test_data) #테스트 데이터를 벡터로 변환한다.

x_train[0]
#array([0., 1., 1., ..., 0., 0., 0.])

y_train = np.asarray(train_labels).astype('float32')
y_test = np.asarray(test_labels).astype('float32')

신경망 모델 만들기

  • 입력 데이터가 벡터이고 레이블은 스칼라(1 또는 0)이다. 이런 문제에 잘 작동하는 네트워크 종류는 relu 활성화 함수를 사용한 완전 연결 층(즉 Dense(16, activation=’relu’))을 그냥 쌓은 것이다.
  • Dense 층에 전달한 매개변수(16)은 은닉 유닛(hidden unit)의 개수이다.
    • 하나의 은닉 유닛은 층이 나타내는 표현 공간에서 하나의 차원이 된다.
    • 2장에서 relu 활성화 함수를 사용한 Dense 층을 다음 텐서 연산을 연결하여 구현했었다.
output = relu(dot(W, input) + b)
  • 16개의 은닉 유닛이 있다는 것은 가중치 행렬 W의 크기가 (input_dimension, 16)이라는 뜻이다.
    • 입력 데이터와 W를 내적하면 입력 데이터가 16차원으로 표현된 공간으로 투영된다(그리고 편향 벡터 b를 더하고 relu 연산을 적용한다)
    • 표현 공간의 차원을 ‘신경망이 내재된 표현을 학습할 때 가질 수 있는 자유도’로 이해할 수 있다.
    • 은닉 유닛을 늘리면(표현 공간을 더 고차원으로 만들면) 신경망이 더욱 복잡한 표현을 학습할 수 있지만 계산 비용이 커지고 원하지 않는 패턴을 학습할 수도 있다. (훈련 데이터에서는 성능이 향상되지만 테스트 데이터에서는 그렇지 않은 패턴)
  • Dense 층을 쌓을 때 두 가지 중요한 구조상의 결정이 필요하다.
    • 얼마나 많은 층을 사용할 것인가?
    • 각 층에 얼마나 많은 은닉 유닛을 둘 것인가?
  • 중간에 있는 은닉 층은 활성화 함수로 relu를 사용하고 마지막 층은 확률을 출력하기 위해 시그모이드 활성화 함수를 사용한다.
    • relu는 음수를 0으로 만드는 함수이다.
    • 시그모이드는 임의의 값을 [0, 1] 사이로 압축하므로 출력 값을 확률처럼 해석할 수 있다.

  • 아래 그림은 이 신경망을 보여준다.

from keras import models
from keras import layers

model = models.Sequential()
model.add(layers.Dense(16, activation='relu', input_shape=(10000,)))
model.add(layers.Dense(16, activation='relu'))
model.add(layers.Dense(1, activation='sigmoid'))
  • Note)
    • relu와 같은 활성화 함수(또는 비선형성(non-linearity)라고도 한다)가 없다면 Dense 층은 선형적인 연산인 내적과 덧셈 2개로 구성된다.
      • output = dot(W, input) + b
    • 그러므로 이 층은 입력에 대한 선형 변환(아핀 변환)만을 학습할 수 있다. 이 층의 가설 공간은 입력 데이터를 16차원의 공간으로 바꾸는 가능한 모든 선형 변환의 집합이다.
    • 이런 가설 공간은 매우 제약이 많으며, 선형 층을 깊게 쌓아도 여전히 하나의 선형 연산이기 때문에 층을 여러 개로 구성하는 장점이 없다. 즉 층을 추가해도 가설 공간이 확장되지 않는다.
    • 가설 공간을 풍부하게 만들어 층을 깊게 만드는 장점을 살리기 위해서는 비선형성 또는 활성화 함수를 추가해야 한다. relu는 딥러닝에서 가장 인기 있는 활성화 함수이다.
    • prelu, elu등 비슷한 다른 함수들도 많다.
  • 마지막으로 손실 함수와 옵티마이저를 선택해야 한다.
    • 이진 분류 문제고 신경망의 출력이 확률이기 때문에 binary_crossentropy 손실이 적합하다. 이 함수가 유일한 선택은 아니고 mean_squared_error도 사용할 수 있다. 확률을 출력하는 모델을 사용할 때는 크로스엔트로피가 최선의 선택이다.
    • 크로스엔트로피(Crossentropy)는 정보 이론(Information Theory) 분야에서 온 개념으로 확률 분포 간의 차이를 측정한다. 여기서는 원본 분포와 예측 분포 사이를 측정한다.
  • 다음은 rmsprop 옵티마이저와 binary_crossentropy 손실 함수로 모델을 설정하는 단계이다. 훈련하는 동안 정확도를 사용하여 모니터링 하겠다.
model.compile(optimizer='rmsprop', loss='binary_crossentropy', metrics=['accuracy'])
  • 케라스에 rmsprop, binary_crossentropy, accuracy가 포함되어 있기 때문에 옵티마이저, 손실 함수, 측정 지표를 문자열로 지정하는 것이 가능하다.
  • 이따금 옵티마이저의 매개변수를 바꾸거나 자신만의 손실 함수, 측정 함수를 전달해야 할 경우가 있다.
  • 전자의 경우에는 아래와 같이 간이 옵티마이저 파이썬 클래스를 사용해 객체를 직접 만들어 optimizer 매개변수에 전달하면 된다.
from keras import optimizers

model.compile(optimizer=optimizers.RMSprop(lr=0.001), loss='binary_crossentropy', metrics=['accuracy'])
  • 후자의 경우는 아래와 같이 loss와 metrics 매개변수에 함수 객체를 전달하면 된다.
from keras import losses
from keras import metrics

model.compile(optimizer=optimizers.RMSprop(lr=0.001), loss=losses.binary_crossentropy, metrics=[metrics.binary_accuracy])

훈련 검증

  • 훈련하는 동안 처음 본 데이터에 대한 모델의 정확도를 측정하기 위해 원본 훈련 데이터에서 10,000의 샘플을 떼어 검증 세트를 만들어야 한다.
x_val = x_train[:10000]
y_val = y_train[:10000]
partial_x_train = x_train[10000:]
partial_y_train = y_train[10000:]
  • 이제 모델을 512개의 샘플씩 미니 배치를 만들어 20번의 에포크 동안 훈련시킨다. 동시에 따로 떼어 놓은 1만 개의 샘플에서 손실과 정확도를 측정할 것이다.
    • 이렇게 하려면 validation_data 매개변수에 검증 데이터를 전달해야 한다.
model.compile(optimizer='rmsprop', loss='binary_crossentropy', metrics=['acc'])
history = model.fit(partial_x_train, partial_y_train, epochs=20, batch_size=512, validation_data=(x_val, y_val))
  • model.fit() 메서드는 History 객체를 반환한다. 이 객체는 훈련하는 동안 발생한 모든 정보를 담고 있는 딕셔너리인 history 속성을 갖고 있다.
history_dict = history.history
history_dict.keys()
#dict_keys(['val_loss', 'val_acc', 'loss', 'acc'])
  • 이 딕셔너리는 훈련과 검증하는 동안 모니터링할 측정 지표당 하나씩 모두 4개의 항목을 담고 있다.
    • 이어지는 두 목록에서 맷플롯립을 사용하여 훈련과 검증 데이터에 대한 손실과 정확도를 그려보자.
import matplotlib.pyplot as plt

history_dict = history.history
loss = history_dict['loss']
val_loss = history_dict['val_loss']

epochs = range(1, len(loss) + 1)

plt.plot(epochs, loss, 'bo', label='Training loss')
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.show()

plt.clf()
acc = history_dict['acc']
val_acc = history_dict['val_acc']

plt.plot(epochs, acc, 'bo', label='Training acc')
plt.plot(epochs, val_acc, 'b', label='Validation acc')
plt.title('Trainging and validation accuracy')
plt.xlabel('Epochs')
plt.ylabel('Accuracy')
plt.legend()
plt.show()

  • 여기에서 볼 수 있듯이 훈련 손실이 에포크마다 감소하고 훈련 정확도는 에포크마다 증가한다.
    • 경사 하강법 최적화를 사용했을 때 반복마다 최소화되는 것이 손실이므로 기대했던대로이다.
    • 검증 손실과 정확도는 이와 같지 않은데, 네 번째 에포크에서 그래프가 역전되는 것을 볼 수 있다.
    • 이것이 훈련 세트에서 잘 작동하는 모델이 처음 보는 데이터에서 잘 작동하지 않을 수 있다고 한 과대적합(overfitting)이다.

훈련된 모델로 새로운 데이터에 대해 예측하기

  • 모델을 훈련 시킨 후에 이를 실전 환경에서 사용하고 싶을 것이다. predict 메서드를 사용해서 어떤 리뷰가 긍정일 확률을 예측할 수 있다.
model.predict(x_test)

추가 실험

  • (생략)

정리

  • 원본 데이터를 신경망에 텐서로 주입하기 위해서는 꽤 많은 전처리가 필요하다. 단어 시퀀스는 이진 벡터로 인코딩될 수 있고 다른 인코딩 방식도 있다.
  • relu 활성화 함수와 함께 Dense 층을 쌓은 네트워크는 여러 종류의 문제에 적용할 수 있어 앞으로 자주 사용하게 될 것이다.
  • 출력 클래스가 2개인 이진 분류 문제에서 네트워크는 하나의 유닛과 sigmoid 활성화 함수를 가진 Dense 층으로 끝나야 한다. 이 신경망의 출력은 확률을 나타내는 0과 1사이의 스칼라 값이다.
  • 이진 분류 문제에서 이런 스칼라 시그모이드 출력에 대해 사용할 손실 함수는 binary_crossentropy이다.
  • rmsprop 옵티마이저는 문제에 상관없이 일반적으로 충분히 좋은 선택이다.
  • 훈련 데이터에 대해 성능이 향상됨에 따라 신경망은 과대적합되기 시작하고 이전에 본적 없는 데이터에서는 결과가 점점 나빠진다. 항상 훈련 세트 이외의 데이터에서 성능을 모니터링 해야 한다.

뉴스 기사 분류: 다중 분류 문제

  • 여기서는 로이터 뉴스를 46개의 상호 배타적인 토픽으로 분류하는 신경망을 만들어 보겠다. 
    • 클래스가 많기 때문에 이 문제는 다중 분류(multiclass classification)의 예이다.
    • 각 데이터 포인트가 정확히 하나의 범주로 분류되기 때문에 좀 더 정확히 말하면 단일 레이블 다중 분류 문제이다.
    • 각 데이터 포인트가 여러 개의 범주에 속할 수 있다면 이것은 다중 레이블 다중 분류 문제가 된다.

로이터 데이터셋

  • IMDB, MINIST와 마찬가지로 로이터 데이터셋도 케라스에 포함되어 있다.
    • IMDB와 마찬가지로 각 샘플은 정수 리스트이다.
from keras.datasets import reuters

(train_data, train_labels), (test_data, test_labels) = reuters.load_data(num_words=10000)

데이터 준비

  • 이전 예제와 동일한 코드를 사용해서 단어를 벡터로 변환한다.
    • (코드 생략)
  • 레이블을 벡터로 바꾸는 방법은 두 가지이다. 레이블의 리스트를 정수 텐서로 변환하는 것과 원-핫 인코딩을 사용하는 것이다.
    • 원-핫 인코딩이 범주형 데이터에 널리 사용되기 때문에 범주형 인코딩(categorical encoding)이라고도 부른다.
def to_one_hot(labels, dimension=46):
results = np.zeros((len(labels), dimension))
for i, label in enumerate(labels):
results[i, label] = 1.
return results

one_hot_train_labels = to_one_hot(train_labels)
one_hot_test_labels = to_one_hot(test_labels)

  • MINIST 예제에서 보았듯이 케라스에는 이를 위한 내장 함수가 이미 있다.
from keras.utils.np_utils import to_categorical

one_hot_train_labels = to_categorical(train_labels)
one_hot_test_labels = to_categorical(test_labels)

모델 구성

  • 이 토픽 분류 문제는 영화 리뷰 분류 문제와 비슷해 보인다. 두 경우 모두 짧은 텍스트를 분류하는 것이기 때문이다.
    • 여기서는 새로운 제약 사항이 추가되었는데, 출력 클래스의 개수가 2에서 46개로 늘어났다는 점이다. 출력 공간의 차원이 훨씬 커졌다.
  • 이전에 사용했던 것처럼 Dense 층을 쌓으면 각 층은 이전 층의 출력에서 제공한 정보만 사용할 수 있다.
    • 한 층이 분류 문제에 필요한 일부 정보를 누락하면 그 다음 층에서 이를 복원할 방법이 없다.
    • 각 층은 잠재적으로 정보의 병목(information bottleneck)이 될 수 있다.
    • 이전 예제에서 16차원을 가진 중간층을 사용했지만 16차원 공간은 46개의 클래스를 구분하기에 너무 제약이 많을 것 같다. 이렇게 규모가 작은 층은 유용한 정보를 완전히 잃게 되는 정보의 병목 지점처럼 동작할 수 있다.
  • 이런 이유로 좀 더 규모가 큰 층을 사용해 보겠다. 64개의 유닛을 사용해 보자.
from keras import models
from keras import layers

model = models.Sequential()
model.add(layers.Dense(64, activation='relu', input_shape=(10000,)))
model.add(layers.Dense(64, activation='relu'))
model.add(layers.Dense(46, activation='softmax'))
  • 이 구조에서 주목해야 할 점이 두 가지 있다.
    • 마지막 Dense 층의 크기가 46이다. 각 입력 샘플에 대해 46차원의 벡터를 출력한다는 뜻이다. 이 벡터의 각 원소(각 차원)는 각기 다른 출력 클래스가 인코딩 된 것이다.
    • 마지막 층에 softmax 활성화 함수가 사용되었다.
  • 이런 문제에 사용할 최선의 손실 함수는 categorical_crossentropy이다.
    • 이 함수는 두 확률 분포 사이의 거리를 추정한다. 여기서는 네트워크가 출력한 확률 분포와 진짜 레이블의 분포 사이의 거리이다.
    • 두 분포 사이의 거리를 최소화하면 진짜 레이블에 가능한 가까운 출력을 내도록 모델을 훈련하게 된다.
model.compile(optimizer='rmsprop', loss='categorical_crossentropy', metrics=['accuracy'])

훈련 검증

  • 훈련 데이터에서 1,000개의 샘플을 떼어 검증 세트로 사용하자.
x_val = x_train[:1000]
partial_x_train = x_train[1000:]

y_val = one_hot_train_labels[:1000]
partial_y_train = one_hot_train_labels[1000:]
  • 20번의 에포크로 모델을 훈련시킨다.
import numpy as np

history = model.fit(partial_x_train, partial_y_train, epochs=20, batch_size=512, validation_data=(x_val, y_val))
  • 손실과 정확도 곡선을 그려보자.
    • (코드 생략)

새로운 데이터에 대해 예측하기

  • 모델 객체의 predict 메서드는 46개의 토픽에 대한 확률 분포를 반환한다.
model.predict(x_test)

레이블과 손실을 다루는 다른 방법

  • 레이블을 인코딩 하는 다른 방법은 다음과 같이 정수 텐서로 변환하는 것이다.
y_train = np.array(train_labels)
y_test = np.array(test_labels)
  • 이 방식을 사용하려면 손실 함수 하나만 바꾸면 된다. categorical_crossentropy는 레이블이 범주형 인코딩되어 있을 것이라고 기대한다.
    • 정수 레이블을 사용할 때는 sparse_categorical_crossentropy를 사용해야 한다.
model.compile(optimizer='rmsprop', loss='sparse_categorical_crossentropy', metrics=['acc'])
  • 이 손실 함수는 인터페이스만 다를 뿐이고 수학적으로는 categorical_crossentropy와 동일하다.

충분히 큰 중간층을 두어야 하는 이유

  • 마지막 출력이 46차원이기 때문에 중간의 히든 유닛이 46개보다 많이 적어져서는 안된다.
  • 46차원보다 훨씬 작은 중간층을 두면 정보의 병목이 어떻게 나타나는지 확인해 보겠다.
    • (생략 – 정확도가 매우 떨어짐)

추가 실험

  • (생략)

정리

  • N개의 클래스로 데이터 포인트를 분류하려면 네트워크의 마지막 Dense 층의 크기는 N이어야 한다.
  • 단일 레이블, 다중 분류 문제에서는 N개의 클래스에 대한 확률 분포를 출력하기 위해 softmax 활성화 함수를 사용해야 한다.
  • 이런 문제에는 항상 범주형 크로스엔트로피를 사용해야 한다. 이 함수는 모델이 출력한 확률 분포와 타깃 분포 사이의 거리를 최소화 한다.
  • 다중 분류에서 레이블을 다루는 두 가지 방법이 있다.
    • 레이블을 범주형 인코딩(또는 원-핫 인코딩)으로 인코딩하고 categorical_crossentropy 손실 함수를 사용한다.
    • 레이블을 정수로 인코딩하고 sparse_categorical_crossentropy 손실 함수를 사용한다.
  • 만은 수의 범주를 분류할 때 중간층의 크기가 너무 작아 네트워크에 정보의 병목이 생기지 않도록 주의해야 한다.

주택 가격 예측: 회귀 문제

  • 앞선 두 예제는 분류 문제로 입력 데이터 포인트의 개별적인 레이블 하나를 예측하는 것이 목적이었다.
  • 또 다른 종류의 머신 러닝 문제는 개별적인 레이블 대신 연속적인 값을 예측하는 회귀(regression)가 있다.

보스턴 주택 가격 데이터셋

  • 1980년 중반 보스턴 외곽 지역에 대한 데이터셋은 이전 2개의 예제와 다른데, 데이터 포인트가 506개로 비교적 적다. 404개는 훈련 샘플, 102개는 테스트 샘플로 나뉘어 있다.
    • 입력 데이터에 있는 특성(feature)은 스케일이 서로 다르다. 어떤 값은 0-1 사이의 비율이고, 어떤 것은 1-12, 1-100 사이의 값을 갖는 것도 있다.
from keras.datasets import boston_housing

(train_data, train_targets), (test_data, test_targets) = boston_housing.load_data()

데이터 준비

  • 상이한 스케일을 가진 값을 신경망에 주입하면 문제가 된다. 
    • 네트워크가 이런 다양한 데이터에 자동으로 맞추려고 할 수 있지만, 이는 확실히 학습을 더 어렵게 만든다. 이런 데이터를 다룰 때 대표적인 방법은 특성별로 정규화를 하는 것이다.
    • 입력 데이터에 있는 각 특성에 대해 특성의 평균을 내고 표준편차로 나눈다. 특성의 중앙이 0 근처에 맞추어지고 표준 편차가 1이 된다. 
    • 넘파이를 사용하면 간단하게 할 수 있다.
mean = train_data.mean(axis=0)
std = train_data.std(axis=0)

train_data -= mean
train_data /= std

test_data -= mean
test_data /= std

모델 구성

  • 샘플 개수가 적기 때문에 64개의 유닛을 가진 2개의 은닉 층으로 작은 네트워크를 구성하여 사용하겠다.
    • 일반적으로 훈련 데이터의 개수가 적을수록 과대적합이 더 쉽게 일어나므로 작은 모델을 사용하는 것이 과대적합을 피하는 방법이다.
from keras import models
from keras import layers

def build_model():
model = models.Sequential()
model.add(layers.Dense(64, activation='relu', input_shape=(train_data.shape[1],)))
model.add(layers.Dense(64, activation='relu'))
model.add(layers.Dense(1))
model.compile(optimizer='rmsprop', loss='mse', metrics=['mae'])
return model
  • 이 네트워크의 마지막 층은 하나의 유닛을 가지고 있고 활성화 함수가 없다 (선형 층이라고 부른다). 이것이 전형적인 스칼라 회귀(하나의 연속적인 값을 예측하는 회귀)를 위한 구성이다.
    • 활성화 함수를 적용하면 출력 값의 범위를 제한하게 된다. 예컨대 마지막 층에 sigmoid 활성화 함수를 적용하면 네트워크가 0-1 사이의 값을 예측하도록 학습될 것이다.
    • 여기서는 마지막 층이 순수한 선형이므로 네트워크가 어떤 범위의 값이라도 예측하도록 자유롭게 학습된다.
  • 이 모델은 mse 손실 함수를 사용하여 컴파일한다.
    • 이 함수는 평균 제곱 오차(mean squared error)의 약어로 예측과 타깃 사이 거리의 제곱이다. 회귀 문제에서 널리 사용되는 손실함수이다.
  • 훈련하는 동안 모니터링을 위해 새로운 지표인 평균 절대 오차(Mean Absolute Error, MAE)를 측정한다.
    • 이는 예측과 타깃 사이 거리의 절댓값이다. 예컨대 이 예제에서 MAE가 0.5면 예측이 평균적으로 500달러 정도 차이가 난다는 뜻이다.

K-겹 검증을 사용한 훈련 검증

  • 매개변수들을 조정하면서 모델을 평가하기 위해 이전 예제에서 했던 것처럼 데이터를 훈련 세트와 검증 세트로 나눈다.
    • 데이터 포인트가 많지 않기 때문에 검증 세트도 매우 작아진다(약 100개)
    • 결국 검증 세트와 훈련 세트로 어떤 데이터 포인트가 선택되었는지에 따라 검증 점수가 크게 달라진다. 검증 세트의 분할에 대한 검증 점수의 분산이 높다. 이렇게 되면 신뢰 있는 모델 평가를 할 수 없다.
  • 이런 상황에서 가장 좋은 방법은 K-겹 교차 검증(K-fold cross-validation)을 사용하는 것이다.
    • 데이터를 K개의 분할(즉 폴드(fold))로 나누고 (일반적으로 K = 4 또는 5), K개의 모델을 각각 만들어 K-1개의 분할에서 훈련하고 나머지 분할에서 평가하는 방법이다.
    • 모델의 검증 점수는 K개의 검증 점수 평균이 된다.

import numpy as np

k = 4

num_val_samples = len(train_data) // k
num_epochs = 100
all_scores = []

for i in range(k):
print('처리 중인 폴드 #', i)
val_data = train_data[i * num_val_samples: (i + 1) * num_val_samples]
val_targets = train_targets[i * num_val_samples: (i + 1) * num_val_samples]

partial_train_data = np.concatenate([train_data[:i*num_val_samples], train_data[(i+1)*num_val_samples:]], axis=0)
partial_train_targets = np.concatenate([train_targets[:i*num_val_samples], train_targets[(i+1)*num_val_samples:]], axis=0)

model = build_model()
model.fit(partial_train_data, partial_train_targets, epochs=num_epochs, batch_size=1, verbose=0)

val_mse, val_mae = model.evaluate(val_data, val_targets, verbose=0)
all_scores.append(val_mae)
  • num_epochs = 100으로 실행하면 다음 결과를 얻을 수 있다.
all_scores
# [2.0956787838794217, 2.220593797098292, 2.859968412040484, 2.40535704039111]

np.mean(all_scores)
# 2.3953995083523267
  • 검증 세트가 다르므로 검증 점수의 변화가 크다. 평균값이 각각의 점수보다 훨씬 신뢰할만 하다. 이것이 K-겹 교차 검증의 핵심이다.
num_epochs = 500
all_nae_histories = []

for i in range(k):
print('처리 중인 폴드 #', i)
val_data = train_data[i * num_val_samples: (i + 1) * num_val_samples]
val_targets = train_targets[i * num_val_samples: (i + 1) * num_val_samples]

partial_train_data = np.concatenate([train_data[:i*num_val_samples], train_data[(i+1)*num_val_samples:]], axis=0)
partial_train_targets = np.concatenate([train_targets[:i*num_val_samples], train_targets[(i+1)*num_val_samples:]], axis=0)

model = build_model()
history = model.fit(partial_train_data, partial_train_targets, epochs=num_epochs, batch_size=1, verbose=0)

mae_history = history.history['val_mean_absolute_error']
all_mae_histories.append(mae_history)
  • 그 다음 모든 폴드에 대해 에포크의 MAE 점수 평균을 계산한다.
average_mae_history = [np.mean([x[i] for x in all_mae_histories]) for i in range(num_epochs)]
  • 그래프로 나타내면 다음과 같다.

  • 이 그래프는 범위가 크고 변동이 심하기 때문에 보기가 어렵다. 다음과 같이 해보자.
    • 곡선의 다른 부분과 스케일이 많이 달느 첫 10개의 데이터 포인트를 제외한다.
    • 부드러운 곡선을 얻기 위해 각 포인트를 이전 포인트의 지수 이동 평균(exponential moving average)으로 대체한다.
def smooth_curve(points, factor=0.9):
smoothed_points = []
for point in points:
if smoothed_points:
previous = smoothed_points[-1]
smoothed_points.append(previous * factor + point * (1-factor))
else:
smoothed_points.append(point)
return smoothed_points

smooth_mae_history = smooth_curve(average_mae_history[10:])

  • 이 그래프를 보면 검증 MAE가 80번째 에포크 이후에 줄어드는 것이 멈추었다. 이 지점 이후로는 과대적합이 시작된다.
  • 모델의 여러 변수에 대한 튜닝이 끝나면 모든 훈련 데이터를 사용하고 최상의 매개변수로 최종 실전에 투입될 모델을 훈련시킨다. 그 다음 테스트 데이터로 성능을 확인한다.
model = build_model()
model.fit(train_data, train_targets, epochs=80, batch_size=16, verbose=0)
test_mse_score, test_mae_score = model.evaluate(test_data, test_targets)

정리

  • 회귀는 분류에서 사용했던 것과는 다른 손실 함수를 사용한다. 평균 제곱 오차(MSE)는 회귀에서 자주 사용되는 손실 함수이다.
  • 비슷하게 회귀에서 사용되는 평가 지표는 분류와 다르다. 당연히 정확도 개념은 회귀에 적용되지 않는다. 일반적인 회귀 지표는 평균 절대 오차(MAE)이다.
  • 입력 데이터의 특성이 서로 다른 범위를 가지면 전처리 단계에서 각 특성을 개별적으로 스케일 조정해야 한다.
  • 가용한 데이터가 적다면 K-겹 검증을 사용하는 것이 신뢰할 수 있는 모델 평가 방법이다.
  • 가용한 훈련 데이터가 적다면 과대적합을 피하기 위해 은닉 층의 수를 줄인 모델이 좋다. (일반적으로 1개 또는 2개)

요약

  • 이제 벡터 데이터를 사용하여 가장 일반적인 머신 러닝인 이진 분류, 다중 분류, 스칼라 회귀 작업을 다룰 수 있다.
  • 보통 원본 데이터를 신경망에 주입하기 전에 전처리해야 한다.
  • 데이터에 범위가 다른 특성이 있다면 전처리 단계에서 각 특성을 독립적으로 스케일 조정해야 한다.
  • 훈련이 진행됨에 따라 신경망의 과대적합이 시작되고 새로운 데이터에 대해 나쁜 결과를 얻게 된다.
  • 훈련 데이터가 많지 않으면 과대적합을 피하기 위해 1개 또는 2개의 은닉 층을 가진 신경망을 사용한다.
  • 데이터가 많은 범주로 나뉘어 있을 때 중간층이 너무 작으면 정보의 병목이 생길 수 있다.
  • 회귀는 분류와 다른 손실 함수와 평가 지표를 사용한다.
  • 적은 데이터를 사용할 때는 K-겹 검증이 신뢰할 수 있는 모델 평가를 도와준다.

케라스 창시자에게 배우는 딥러닝/ 시작하기 전에: 신경망의 수학적 구성 요소

신경망과의 첫 만남

  • 딥러닝의 “hello world”인 MNIST 데이터셋을 이용해서  신경망 예제를 살펴보겠다.
    • 실제 실습은 3장에서 할 것이고, 여기서는 개념만 살펴본다.
  • 머신 러닝에서 분류 문제의 범주(category)를 클래스(class)라고 한다. 데이터 포인트는 샘플(sample)이라고 한다. 특정 샘플의 클래스는 레이블(label)이라고 한다.

  • MNIST 데이터셋은 넘파이(NumPy) 배열 형태로 케라스에 이미 포함되어 있다.
from keras.datasets import mnist

(train_images, train_labels), (test_images, test_labels) = mnist.load_data()
  • train_images와 train_labels가 모델이 학습해야 할 훈련 세트(training set)를 구성한다. 모델은 test_images와 test_labels로 구성된 테스트 세트(test set)에서 테스트될 것이다.
  • 이미지는 넘파이 배열로 인코딩 되어 있고 레이블은 0-9까지의 숫자배열이다. 이미지와 레이블은 일대일 관계이다.
  • 훈련 데이터와 테스트 데이터를 살펴 보자.
train_images.shape
# (60000, 28, 28)

len(train_labels)
# 60000

train_labels
# array([5, 0, 4, ... , 5, 6, 8], dtype=unit8)

test_images.shape
# (10000, 28, 28)

len(test_labels)
# 10000

test_labels
# array([7, 2, 1, ... , 4, 5, 6], dtype=unit8)
  • 작업 순서는 다음과 같다.
    • 먼저 훈련 데이터 train_images와 train_labels를 네트워크에 주입한다.
    • 그러면 네트워크는 이미지와 레이블을 연관시킬 수 있도록 학습된다.
    • 마지막으로 test_images에 대한 예측을 네트워크에 요청한다.
    • 그리고 이 예측이 test_labels와 맞는지 확인할 것이다.
  • 신경망을 만들어 보자
from keras import models
from keras import layers

network = models.Sequential()
network.add(layers.Dense(512, activation='relu', input_shape=(28 * 28,)))
network.add(layers.Dense(10, activation='softmax'))
  • 신경망의 핵심 고숭요소는 일종의 데이터 처리 필터라고 생각할 수 있는 층(layer)이다. 어떤 데이터가 들어가면 더 유용한 형태로 출력된다.
    • 좀 더 구체적으로 층은 주어진 문제에 더 의미 있는 표현(representation)을 입력된 데이터로부터 추출한다.
    • 대부분의 딥러닝은 간단한 층을 연결하여 구성되어 있고, 점진적으로 데이터를 정제하는 형태를 띠고 있다.
    • 딥러닝 모델은 데이터 정제 필터(층)가 연속되어 있는 데이터 프로세싱을 위한 여과기와 같다.
  • 이 예에서는 조밀하게 연결된 (또는 완전 연결(fully connected)된) 신경망 층인 Dense 층 2개가 연속되어 있다.
    • 두 번째 (즉 마지막) 층은 10개의 확률 점수가 들어 있는 배열(모두 더하면 1)을 반환하는 소프트맥스(softmax) 층이다.
    • 각 점수는 현재 숫자 이미지가 10개의 숫자 클래스 중 하나에 속할 확률이 된다.
  • 신경망이 훈련 준비를 마치기 위해 컴파일 단계에 포함될 세 가지가 더 필요하다.
    • 손실 함수(loss function): 훈련 데이터에서 신경망의 성능을 측정하는 방법으로 네트워크가 옳은 방향으로 학습될 수 있도록 도와준다.
    • 옵티마이저(optimizer): 입력된 데이터와 손실 함수를 기반으로 네트워크를 업데이트하는 메커니즘
    • 훈련과 테스트 과정을 모니터링할 지표: 여기서는 정확도(정확히 분류된 이미지의 비율)만 고려하겠다.
  • 손실 함수와 옵티마이저의 정확한 목적은 이어지는 장에서 자세히 설명하겠다.
network.compile(optimizer='rmsprop', loss='categorical_crossentropy', metrics=['accuracy'])
  • 훈련을 시작하기 전에 데이터를 네트워크에 맞는 크기로 바꾸고 모든 값을 0과 1사이로 스케일 조정한다.
    • 예컨대 우리의 훈련 이미지는 [0, 255] 사이의 값인 uint8 타입의 (60000, 28 * 28) 크기를 가진 배열로 저장되어 있는데, 이 데이터를 0과 1사이의 값을 가지는 float32 타입의 (60000, 28 * 28) 크기인 배열로 바꾼다.
train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype('float32') / 255

test_images = test_images.reshape((10000, 28 * 28))
test_images = test_images.astype('float32') / 255
  • 또 레이블을 범주형으로 인코딩해야 하는데, 이 단계는 다음 장에서 설명하겠다.
from keras.utils import to_categorical

train_labels = to_categorical(train_labels)
test_labels = to_categorical(test_labels)
  • 이제 신경망을 훈련시킬 준비가 되었다. 케라스에서는 fit 메서드를 호출하여 데이터에 모델을 학습 시킨다.
network.fit(train_images, train_labels, epochs=5, batch_size=128)

# Epoch 1/5
# 60000/60000 [===========================================] - 1s 22us/step - loss: 0.2571 - acc: 0.9257
# Epoch 2/5
# 60000/60000 [===========================================] - 1s 12us/step - loss: 0.1027 - acc: 0.9695
# Epoch 3/5
# 60000/60000 [===========================================] - 1s 12us/step - loss: 0.0686 - acc: 0.9797
# Epoch 4/5
# 60000/60000 [===========================================] - 1s 12us/step - loss: 0.0494 - acc: 0.9856
# Epoch 5/5
# 60000/60000 [===========================================] - 1s 12us/step - loss: 0.0368 - acc: 0.9894
  • 훈련 하는 동안 2개의 정보가 출력되는데, 훈련 데이터에 대한 네트워크의 손실과 정확도이다.
  • 훈련 데이터에 대해 0.989(98.9%)의 정확도를 금방 달성했다. 테스트 세트에서도 모델이 잘 작동하는지 확인해 보자.
test_loss, test_acc = network.evaluate(test_images, test_labels)
# 10000/10000 [===========================================] - 0s 16us/step

print('test_acc:', test_acc)
# test_acc: 0.9789
  • 테스트 세트의 정확도는 97.8%로 나왔다.
    • 훈련 세트 정확도 보다 약간 낮은데, 훈련 정확도와 테스트 정확도 사이의 차이는 과대적합(overfitting) 때문이다. 이는 머신 러닝 모델이 훈련 데이터보다 새로운 데이터에서 성능이 낮아지는 경향을 말한다.
    • 과대적합에 대해서는 3장에서 자세히 논의하겠다.

신경망을 위한 데이터 표현

  • 최근의 모든 머신 러닝 시스템은 일반적으로 텐서를 기본 데이터 구조로 사용한다. 텐서는 머신 러닝의 기본 구성 요소이다.
  • 텐서는 데이터를 위한 컨테이너(container)이다.
    • 거의 항상 수치형 데이터를 다루므로 숫자를 위한 컨테이너이다.
    • 텐서는 임의의 차원 개수를 가지는 행렬의 일반화된 모습이다. (텐서에서는 차원(dimension)을 종종 축(axis)라고 부른다.)

스칼라(0D 텐서)

  • 하나의 숫자만 담고 있는 텐서를 스칼라(scalar) (또는 스칼라 텐서, 0차원 텐서, 0D 텐서)라고 부른다.
    • 넘파이에서는 float32, float64 타입의 숫자가 스칼라 텐서(또는 배열 스칼라(array scalar)) 이다.
    • ndim 속성을 사용하면 넘파이 배열의 축 개수를 확인할 수 있다.
    • 스칼라 텐서의 축 개수는 0이다(ndim==0)
    • 텐서의 축 개수를 랭크(rank)라고 부른다.

벡터(1D 텐서)

  • 숫자의 배열을 벡터(vector) 또는 1D 텐서라고 부른다.
    • 1D 텐서는 딱 하나의 축을 가진다.
    • 5개의 원소를 가진 배열을 5차원 벡터라고 부르는데, 5D 벡터와 5D 텐서를 혼동하지 말 것

행렬(2D 텐서)

  • 벡터의 배열이 행렬(matrix) 또는 2D 텐서이다.
    • 행렬에는 2개의 축이 있다. (보통 행(row)과 열(column)이라 부른다)
    • 행렬은 숫자가 채워진 사각 격자라고 생각할 수 있다.
    • 첫 번째 축에 놓여 있는 원소를 행이라 부르고, 두 번째 축에 놓여 있는 원소를 열이라 부른다.

3D 텐서와 고차원 텐서

  • 이런 행렬들을 하나의 새로운 배열로 합치면 숫자가 채워진 직육면체 형태로 해석할 수 있는 3D 텐서가 만들어진다.
  • 3D 텐서들을 하나의 배열로 합치면 4D 텐서가 만들어진다.
  • 딥러닝에서는 보통 0D에서 4D까지 텐서를 다루는데, 동영상 데이터를 다룰 때에는 5D 텐서까지 가기도 한다.

핵심 속성

  • 텐서는 4개의 핵심 속성으로 정의된다.
    • 축의 개수(rank): 예컨대 3D 텐서에는 3개의 축이 있고, 행렬에는 2개의 축이 있다. 넘파이 라이브러리에서는 ndim 속성에 저장되어 있다.
    • 크기(shape): 텐서의 각 축을 따라 얼마나 많은 차원이 있는지를 나타낸 파이썬의 튜플(tuple)이다.
      • 예컨대 행렬의 크기는 (3, 5), 3D 텐서의 크기는 (3, 3, 5)와 같이 표현된다. 벡터의 크기는 (5,)처럼 1개의 원소로 이루어진 튜플이고 배열 스칼라는 ()처럼 크기가 없다.
    • 데이터 타입(넘파이에서는 dtype에 저장된다): 텐서에 포함된 데이터의 타입이다. 예컨대 텐서의 타입은 float32, uint8, float64 등이 될 수 있다.
  • (크기 살펴보는 예시 코드 생략)

넘파이로 텐서 조작하기

  • train_images[i] 같은 형식으로 첫 번째 축을 따라 특정 숫자를 선택했는데, 배열에 있는 특정 원소들을 선택하는 것을 슬라이싱(slicing)이라고 한다. 넘파이 배열에서 할 수 있는 슬라이싱 연산을 살펴보자.
  • 다음 예는 11번째에서 101번째까지 숫자를 선택하여 (90, 28, 28) 크기의 배열을 만든다.
my_slice = train_images[10:100]
print(my_slice.shape)
# (90, 28, 28)
  • 동일하지만 조금 더 자세한 표기법은 각 배열의 축을 따라 슬라이싱의 시작 인덱스와 마지막 인덱스를 지정하는 것이다. :(콜론)은 전체 인덱스를 선택한다.
my_slice = train_images[10:100, :, :]
print(my_slice.shape)
# (90, 28, 28)

my_slice = train_images[10:100, 0:28, 0:28]
print(my_slice.shape)
# (90, 28, 28)
  • 일반적으로 각 배열의 축을 따라 어떤 인덱스 사이도 선택할 수 있다. 예컨대 이미지의 오른쪽 아래 14×14 픽셀을 선택하려면 다음과 같이 한다.
my_slice = train_images[:, 14:, 14:]
  • 음수 인덱스도 사용할 수 있다. 파이썬 리스트의 음수 인덱스와 마찬가지로 현재 축의 끝에서 상대적인 위치를 나타낸다.
    • 정중앙에 위치한 14×14 픽셀 조각을 이미지에서 잘라 내려면 다음과 같이 하면 된다.
my_slice = train_images[:, 7:-7, 7:-7]

배치 데이터

  • 일반적으로 딥러닝에서 사용하는 모든 데이터의 텐서의 첫 번째 축(인덱스가 0부터 시작하므로 0번째 축)은 샘플 축(sample axis)이다. (이따금 샘플 차원이라고도 부른다)
    • MNIST 예제에서는 숫자 이미지가 샘플이다.
  • 딥러닝 모델은 한 번에 전체 데이터셋을 처리하지 않는다. 대신 데이터를 작은 배치(batch)로 나눈다. 구체적으로 말하면 MNIST 숫자 데이터에서 크기가 128인 배치 하나는 다음과 같다.
batch = train_images[:128]
  • 그 다음 배치는 다음과 같다.
batch = train_images[128:256]
  • n번째 배치는 다음과 같다.
batch = train_images[128 * n:128 * (n + 1)]
  • 이런 배치 데이터를 다룰 때는 첫 번째 축(0번 축)을 배치 축(batch axis) 또는 배치 차원(batch dimension)이라고 부른다.

텐서의 실제 사례

  • 우리가 사용할 데이터는 대부분 다음 중 하나에 속할 것이다.
    • 벡터 데이터: (samples, features) 크기의 2D 텐서
    • 시계열 데이터 또는 시퀀스(sequence) 데이터: (samples, timesteps, features) 크기의 3D 텐서
    • 이미지: (samples, height, width, channels) 크기의 4D 텐서
    • 동영상: (samples, frames, height, width, height, channels) 크기의 5D 텐서

벡터 데이터

  • 대부분의 경우에 해당한다. 이런 데이터셋에서는 하나의 데이터 포인트가 벡터로 인코딩될 수 있으므로 배치 데이터는 2D 텐서로 인코딩될 것이다.
    • 여기서 첫 번째 축은 샘플 축이고, 두 번째 축은 특성 축(feature axis)이다.

시계열 데이터 또는 시퀀스 데이터

  • 데이터에서 시간이 (또는 연속된 순서가) 중요할 떄는 시간 축을 포함하여 3D 텐서로 저장된다.
    • 각 샘플은 벡터(2D 텐서)의 시퀀스로 인코딩 되므로 배치 데이터는 3D 텐서로 인코딩 될 것이다.
    • 관례적으로 시간 축은 항상 두 번째 축(인덱스가 1인 축)이다.

이미지 데이터

  • 이미지는 전형적으로 높이, 너비, 컬러의 3차원으로 이루어진다.
    • 흑백 이미지는 컬러 채널만 갖고 있어 2D 텐서로 저장할 수 있지만 관례상 항상 3D로 저장한다. 흑백 이미지의 경우 컬러 채널 차원의 크기는 1이다.
  • 이미지 텐서의 크기를 지정하는 방식은 두 가지인데, 텐서플로에서 사용하는 채널 마지막(channel-last) 방식과 씨아노에서 사용하는 채널 우선(channel-first) 방식이다.
    • 케라스 프레임워크는 두 형식을 모두 지원한다.

비디오 데이터

  • 비디오 데이터는 5D 텐서가 필요한 몇 안되는 데이터 중 하나로 하나의 미비도는 프레임의 연속이고 각 프레임은 하나의 컬러 이미지이다.

신경망의 톱니바퀴: 텐서 연산

  • 심층 신경망이 학습한 모든 변환을 수치 데이터 텐서에 적용하는 몇 종류의 텐서 연산(tensor operation)으로 나타낼 수 있다.
  • 첫 번째 예제에서는 Dense 층을 쌓아서 신경망을 만들었다. 케라스의 층은 다음과 같이 생성한다.
keras.layers.Dense(512, activation='relu')
  • 이 층은 2D 텐서를 입력으로 받고 입력 텐서의 새로운 표현인 또 다른 2D 텐서를 반환하는 함수처럼 해석할 수 있다.
    • 구체적으로 보면 이 함수는 다음과 같다. (W는 2D 텐서고, b는 벡터이다. 둘 모두 층의 속성이다)
output= = relu(dot(W, input) + b)
  • 좀 더 자세히 알아보자. 여기에는 3개의 텐서 연산이 있다.
    • 입력 텐서와 텐서 W 사이의 점곱(dot), 점곱의 결과인 2D 텐서와 벡터 b 사이의 덧셈, 마지막으로 relu(렐루) 연산이다.
    • relu(x)는 max(x, 0)이다.

원소별 연산

  • relu 함수와 덧셈은 원소별 연산(element-wise operation)이다. 이 연산은 텐서에 있는 각 원소에 독립적으로 적용된다.
    • 이 말은 고도의 병렬 구현(1970-1990년대 슈퍼컴퓨터의 구조인 벡터 프로세서(vector processor)에서 온 용어인 벡터화된 구현을 말한다)이 가능한 연산이라는 의미이다.
    • 파이썬에서 단순한 원소별 연산을 구현한다면 for 반복문을 사용해야 할 것이다.
    • (예시 코드 생략)
  • 사실 넘파이 배열을 다룰 때는 최적화된 넘파이 내장 함수로 이런 연산들을 처리할 수 있다.
    • 넘파이는 시스템에 설치된 BLAS(Basic Linear Algebra Subprogram) 구현에 복잡한 일들을 위임한다.
    • BLAS는 고도로 병렬화되고 효율적인 저수준의 텐서 조작 루틴이며, 전형적으로 포트란(Fortran)이나 C 언어로 구현되어 있다.
    • 넘파이는 원소별 연산을 엄청난 속도로 처리한다.

브로드캐스팅

  • Dense 층에서는 2D 텐서와 벡터를 더했는데, 크기가 다른 두 텐서가 더해질 때 무슨 일이 일어날까?
  • 모호하지 않고 실행 가능하다면 작은 텐서가 큰 텐서의 크기에 맞추어 브로드캐스팅(broadcasting) 된다. 브로드캐스팅은 두 단계로 이루어진다.
    • 큰 텐서의 ndim에 맞도록 작은 텐서에 (브로드캐스팅 축이라고 부르는) 축이 추가된다.
    • 작은 텐서가 새 축을 따라서 큰 텐서의 크기에 맞도록 반복된다.
  • 구현 입장에서 새로운 텐서가 만들어지면 매우 비효율적이므로 어떤 2D 텐서도 만들어지지 않는다. 반복된 연산은 완전히 가상적이다. 이 과정은 메모리 수준이 아니라 알고리즘 수준에서 일어난다.
    • 하지만 새로운 축을 따라 벡터가 반복된다고 생각하는 것이 이해하기 쉽다.

텐서 점곱

  • 텐서 곱셈(tensor product)이라고도 부르는 점곱 연산(dot operation)은 가장 널리 사용되고 유용한 텐서 연산이다. 원소별 연산과 반대로 입력 텐서의 원소들을 결합시킨다.
    • 넘파이, 케라스, 씨아노, 텐서플로에서 원소별 곱셈은 * 연산자를 사용한다.
    • 텐서플로에서는 dot 연산자가 다르지만 넘파이와 케라스는 점곱 연산에 보편적인 dot 연산자를 사용한다.
  • (예시 코드 생략)
  • 두 벡터의 점곱은 스칼라가 되므로 원소 개수가 같은 벡터끼리 점곱이 가능하다.
    • 행렬 x와 벡터 y 사이에서도 점곱이 가능하다. y와 x의 행 사이에서 점곱이 일어나므로 벡터가 반환된다.
    • (사실 행렬의 곱셈은 행렬의 각 벡터간 점곱(내적)의 결과이다)
  • 점곱은 임의의 축 개수를 가진 텐서에 일반화 된다. 가장 일반적인 용도는 두 행렬 간의 점곱일 것이다.

  • x, y, z는 직사각형 모양으로 그려져 있다. x의 행 벡터와 y의 열 벡터가 같은 크기여야 하므로 자동으로 x의 너비는 y의 높이와 동일해야 한다.
  • 더 일반적으로 크기를 맞추는 동일한 규칙을 따르면 다음과 같이 고차원 텐서 간의 점곱을 할 수 있다.

(a, b, c, d) \cdot (d, ) \to (a, b, c)

(a, b, c, d) \cdot (d, e) \to (a, b, c, e)

텐서 크기 변환

  • 꼭 알아두어야 할 세 번째 텐서 연산은 텐서 크기 변환(tensor reshaping)이다.
  • 텐서의 크기를 변환한다는 것은 특정 크기에 맞게 열과 행을 재배열한다는 뜻이다. 당연히 크기가 변환된 텐서는 원래 텐서와 원소 개수가 동일하다.
    • (예시 코드 생략)
  • 자주 사용하는 특별한 크기 변환은 전치(transposition)이다. 전치는 행과 열을 바꾸는 것을 의미한다.
    • 즉 x[i, :]이 x[:, i]가 된다.

텐서 연산의 기하학적 해석

  • 텐서 연산이 조작하는 텐서의 내용은 어떤 기하학적 공간에 있는 좌표 포인트로 해석될 수 있기 때문에 모든 텐서 연산은 기하학적 해석이 가능하다.
  • 다음 벡터를 보자.
A = [0.5, 1]
  • 이 포인트는 2-6 이미지와 같은 2D 공간에 있다. 일반적으로 2-7이미지와 같이 원점에서 포인트를 연결하는 화살표로 벡터를 나타낸다.

  • 새로운 포인트 B = [1, 0.25]를 이전 벡터에 더해 보자. 기하학적으로는 벡터 화살표를 연결하여 계산할 수 있다. 최종 위치는 두 벡터의 덧셈을 나타내는 벡터가 된다.

  • 일반적으로 아핀 변환(affine transformation), 회전, 스케일링(scaling) 등처럼 기본적인 기하학적 연ㅅ나은 텐서 연산으로 표현될 수 있다.
    • 예컨대 \theta 각도로 2D 벡터를 회전하는 것은 2 x 2 행렬 R = [u, v] 를 접곱하여 구할 수 있다.
    • 여기에서 u, v 는 동일 평면상의 벡터이며, u = [cos(\theta), sin(\theta)], v = [-sin(\theta), cos(\theta)] 이다.

딥러닝의 기하학적 해석

  • 신경망은 전체적으로 텐서 연산의 연결로 구성된 것이고, 모든 텐서 연산은 입력 데이터의 기하학적 변환임을 배웠다.
    • 단순한 단계들이 길게 이어져 구현된 신경망을 고차원 공간에서 매우 복잡한 기하학적 변환을 하는 것으로 해석할 수 있다.
  • 3D라면 다음 비유가 이해하는데 도움이 될 것이다.
    • 하나는 빨간색이고 다른 하나는 파란색인 2개의 색종이가 있다고 가정하자.
    • 두 장을 겹친 다음 뭉쳐서 작은 공으로 만든다.
    • 이 종이 공이 입력 데이터이고 색종이는 분류 문제의 데이터 클래스이다.
    • 신경망(또는 다른 머신 러닝 알고리즘)이 해야 할 일은 종이 공을 펼쳐서 두 클래스가 다시 깔끔하게 분리되는 변환을 찾는 것이다.
    • 손가락으로 종이 공을 조금씩 펼치는 것처럼 딥러닝을 사용하여 3D 공간에서 간단한 변환들을 연결해서 이를 구현한다.

  • 종이 공을 펼치는 것이 머신 러닝이 하는 일이다. 복잡하고 심하게 꼬여 있는 데이터의 매니폴드에 대한 깔끔한 표현을 찾는 일이다.
    • 이쯤되면 딥러닝이 왜 이런 작업에 뛰어난지 알았을 것이다.
    • 기초적인 연산을 길게 연결하여 복잡한 기하학적 변환을 조금씩 분해하는 방식이 마치 사람이 종이 공을 펼치기 위한 전략과 매우 흡사하기 때문이다.
  • 심층 네트워크의 각 층은 데이터를 조금씩 풀어 주는 변환을 적용하므로, 이런 층을 깊게 쌓으면 아주 복잡한 분해 과정을 처리할 수 있다.

신경망의 엔진: 그래디언트 기반 최적화

  • 이전 절에서 보았듯이 첫 번째 신경망 예제에 있는 각 층은 입력 데이터를 다음과 같이 변환한다.
output = relu(dot(W, intput) + b)
  • 이 식에서 텐서 W와 b는 층의 속성처럼 볼 수 있다.
    • 가중치(weight) 또는 훈련되는 파라미터(trainable parameter)라고 부른다. (각각 커널(kernel)과 편향(bias)라고 부르기도 한다) 이런 가중치에는 훈련 데이터를 신경망에 노출시켜서 학습된 정보가 담겨 있다.
  • 초기에는 가중치 행렬이 작은 난수로 채워져 있다. (무작위 초기화(random initialization) 단계라고 부른다)
    • 물론 W와 b가 난수일 때 relu(dot(W, input) + b)가 유용한 어떤 표현을 만들 것이라고 기대할 수는 없다.
    • 즉 의미없는 표현이 만들어진다. 하지만 이는 시작 단계일 뿐이다.
    • 그 다음에는 피드백 신호에 기초하여 가중치가 점진적으로 조정될 것이다.
    • 이런 점진적인 조정 또는 훈련(training)이 머신 러닝 학습의 핵심이다.
  • 훈련은 다음과 같은 훈련 반복 루프(training loop) 안에서 일어난다. 필요한 만큼 반복 루프 안에서 이런 단계가 반복된다.
    1. 훈련 샘플 x와 이에 상응하는 타깃 y의 배치를 추출한다.
    2. x를 사용하여 네트워크를 실행하고(정방향 패스(forward pass) 단계), 예측 y_pred를 구한다.
    3. y_pred와 y의 차이를 측정하여 이 배치에 대한 네트워크의 손실을 계산한다.
    4. 배치에 대한 손실이 조금 감소되도록 네트워크의 모든 가중치를 업데이트한다.
  • 결국 훈련 데이터에서 네트워크의 손실, 즉 예측 y_pred와 타깃 y의 오차가 매우 작아질 것이다.
    • 이 네트워크는 입력에 정확한 타깃을 매핑하는 것을 학습했다.
    • 전체적으로 보면 마술처럼 보이지만 개별적인 단계로 쪼개어보면 단순하다.
  • 1단계는 입출력 코드이므로 매우 쉽다. 2-3단계는 몇 개의 텐서 연산을 적용할 뿐이므로 이전 절에 배웠던 연산을 사용하여 이 단계를 구현할 수 있다.
    • 어려운 부분은 네트워크의 가중치를 업데이트하는 4단계이다. 개별적인 가중치 값이 있을 때 값이 증가해야 할지 감소해야 할지 또 얼마만큼 업데이트해야 할지 어떻게 알 수 있을까?
  • 한 가지 간단한 방법은 네트워크 가중치 행렬의 원소를 모두 고정하고 관심 있는 하나만 다른 값을 적용해 보는 것이다.
    • 이 가중치의 초깃값이 0.3이고 배치 데이터를 정방향 패스에 통과시킨 후 네트워크 손실이 0.5가 나왔다고 하자.
    • 이 가중치를 0.35로 변경하고 다시 정방향 패스를 실행했더니 손실이 0.6으로 증가했다.
    • 반대로 0.25로 줄이면 손실이 0.4로 감소했다. 이 경우에 가중치를 -0.05만틈 업데이트한 것이 손실을 줄이는데 기여한 것으로 보인다.
    • 이런 식으로 네트워크의 모든 가중치에 반복한다.
  • 이런 접근 방식은 모든 가중치 행렬의 원소마다 두 번의 (비용이 큰) 정방향 패스를 계산해야 하므로 엄청나게 비효율적이다. (보통 수천에서 경우에 따라 수백만 개의 많은 가중치가 있다.)
    • 신경망에 사용된 모든 연산이 미분가능(differentiable) 하다는 장점을 사용하여 네트워크 가중치에 대한 손실의 그래디언트(gradient)를 계산하는 것이 훨씬 더 좋은 방법이다.
    • 그래디언트의 반대 방향으로 가중치를 이동하면 손실이 감소된다.

변화율이란?

  • 실수 x를 새로운 실수 y로 매핑하는 연속적이고 매끄러운 함수 f(x) = y를 생각해 보자.
    • 이 함수가 연속적이므로 x를 조금 바꾸면 y가 조금 변경될 것이다. 이것이 연속성의 개념이다.
    • x를 작은 값 epsilon_x 만큼 증가시켰을 때 y가 epsilon_y 만큼 바뀐다고 말할 수 있다.
f(x + eplsion_x) = y + epsion_y
  • 또 이 함수가 매끈하므로(곡선의 각도가 갑자기 바뀌지 않는다) epsilon_x가 충분히 작다면 어떤 포인트 p에서 기울기 a의 선형 함수로 f를 근사할 수 있다.
    • 따라서 epsilon_y는 a * epsilon_x 가 된다.
f(x + eplsion_x) = y + a * epsion_x
  • 이 선형적인 근사는 x가 p에 충분히 가까울 때 유효하다.
  • 이 기울기를 p에서 f의 변화율(derivative)라고 한다.
    • 이는 a가 음수일 때 p에서 양수 x만큼 조금 이동하면 f(x)가 감소한다는 것을 의미한다.
    • a가 양수일 때는 음수 x만큼 조금 이동하면 f(x)가 감소된다.
    • a의 절댓값(변화율의 크기)은 이런 증가나 감소가 얼마나 빠르게 일어날지 알려준다.

  • 모든 미분 가능한(미분 가능하다는 것은 변화율을 유도할 수 있다는 의미로, 예컨대 매끄럽고 연속적인 함수이다) 함수 f(x)에 대해 x의 값을 f의 국부적인 선형 근사인 그 지점의 기울기로 매핑하는 변화율 함수 f'(x)가 존재한다.
    • 예컨대 cos(x)의 변화율은 -sin(x)이고, f(x) = a * x의 변화율은 f'(x) = a이다.
  • f(x)를 최소화하기 위해 epsilon_x 만큼 x를 업데이트하고 싶을 때 f의 변화율을 알고 있으면 해결된다.
    • 변화율 함수는 x가 바뀜에 따라 f(x)가 어떻게 바뀔지 설명해 준다.
    • f(x)의 값을 감소 시키고 싶다면 x를 변화율의 방향과 반대로 조금 이동해야 한다.

텐서 연산의 변화율: 그래디언트

  • 그래디언트는 텐서 연산의 변화율이다. 이는 다차원 입력, 즉 텐서를 입력으로 받는 함수에 변화율 개념을 확장시킨 것이다.
  • 입력 벡터 x, 행렬 W, 타깃 y와 손실 함수 loss가 있다고 가정하자.
    • W를 사용하여 타깃의 예측 y_pred를 계산하고 손실, 즉 타깃 예측 y_pred와 타깃 y사이의 오차를 계산할 수 있다.
y_pred = dot(W, x)
loss_value = loss(y_pred, y)
  • 입력 데이터 x와 y가 고정되어 있다면 이 함수는 W를 손실 값에 매핑하는 함수로 볼 수 있다.
loss_value = f(W)
  • W의 현재 값을 W0라고 하자.
    • 포인트 W0에서 f의 변화율은 W와 같은 크기의 텐서인 gradient(f)(W0)이다.
    • 이 텐서의 각 원소 gradient(f)(W0)[i, j]는 W0[i, j]를 변경했을 때 loss_value가 바뀌는 방향과 크기를 나타낸다.
    • 다시 말해 gradient(f)(W0)가 W0)에서 함수 f(W) = loss_value의 그래디언트이다.
  • 앞서 함수 f(x)의 변화율 하나는 곡선 f의 기울기로 해석할 수 있다는 것을 보았다. 비슷하게 gradient(f)(W0)는 W0에서 f(W)의 기울기를 타나내는 텐서로 해석할 수 있다.
    • 그렇기 때문에 함수 f(x)에 대해서는 변화율의 반대 방향으로 x를 조금 움직이면 f(x)의 값을 감소시킬 수 있다.
    • 동일한 방식을 적용하면 함수 f(W)의 입장에서는 그래디언트의 반대 방향으로 W를 움직이면 f(W)의 값을 줄일 수 있다.
    • 예컨대 W1 = W0 – step * gradient(f)(W0)이다. (step은 스케일을 조정하기 위한 작은 값이다)
    • 이 말은 기울기가 작아지는 곡면의 낮은 위치로 이동된다는 의미이다.
    • gradient(f)(W0)는 W0)에 아주 가까이 있을 때 기울기를 근사한 것이므로 W0에서 너무 크게 벗어나지 않기 위해 스케일링 비율 step이 필요하다.

확률적 경사 하강법

  • 미분 가능한 함수가 주어지면 이론적으로 이 함수의 최솟값을 해석적으로 구할 수 있다.
    • 함수의 최솟값은 변화율이 0인 지점이다. 따라서 우리가 할 일은 변화율이 0이 되는 지점을 모두 찾고 이 중에서 어떤 포인트의 함수 값이 가장 작은지 확인하는 것이다.
  • 신경망에 적용하면 가장 작은 손실 함수의 값을 만드는 가중치의 조합을 해석적으로 찾는 것을 의미한다.
    • 이는 식 gradient(f)(W) = 0을 풀면 해결된다. 이 식은 N개의 변수로 이루어진 다항식이다.
    • 여기서 N은 네트워크 가중치 개수이다. N=2, N=3인 식을 푸는 것은 가능하지만 실제 신경망에서는 파라미터의 개수가 수천 개보다 적은 경우가 거의 없고 종종 수천만 개가 되기 때문에 해석적으로 해결하는 것이 어렵다.
  • 그 대신 앞서 설명한 알고리즘 네 단계를 사용할 수 있다. 랜덤한 배치 데이터에서 현재 손실 값을 토대로 하여 조금씩 파라미터를 수정하는 것이다.
    • 미분 가능한 함수를 가지고 있으므로 그래디언트를 계산하여 단계 4를 효율적으로 구현할 수 있다. 그래디언트 방향으로 가중치를 업데이트 하면 손실이 매번 조금씩 감소할 것이다.
  • 그래디언트 계산을 반영하여 업데이트
    1. 훈련 샘플 x와 이에 상응하는 타깃 y의 배치를 추출한다.
    2. x를 사용하여 네트워크를 실행하고 예측 y_pred를 구한다.
    3. y_pred와 y의 차이를 측정하여 이 배치에 대한 네트워크의 손실을 계산한다.
    4. 네트워크의 파라미터에 대한 손실 함수의 그래디언트를 계산한다(역방향 패스(backward pass))
    5. 그래디언트의 반대 방향으로 파라미터를 조금 이동시킨다. 예컨대 W -= step * gradient 처럼 하면 배치에 댛나 손실이 조금 감소할 것이다.
  • 이것이 바로 미니 배치 확률적 경사 하강법(mini-batch stochastic gradient descent)(미니 배치 SGD)이다.
    • 확률적(stochastic)이란 단어는 각 배치 데이터가 무작위로 선택된다는 의미이다. (확률적이란 것은 무작위(random) 하다는 것의 과학적 표현이다.)
    • 네트워크의 파라미터와 훈련 샘플이 하나일 때 이 과정을 아래 그림에 나타냈다.

  • 그림에서 볼 수 있듯이 step 값을 적절히 고르는 것이 중요하다.
    • 이 값이 너무 작으면 곡선을 따라 내려가는데 너무 많은 반복이 필요하고 지역 최솟값(local minimum)에 갇힐 수 있다.
    • step이 너무 크면 손실 함수 곡선에서 완전히 임의의 위치로 이동시킬 수 있다.
  • 미니 배치 SGD 알고리즘의 한 가지 변종은 반복마다 하나의 샘플과 하나의 타깃을 뽑는 것이다.
    • 이것이 (미니 배치 SGD와 반대로) 진정한(true) SGD이다.
    • 다른 한편으로 극단적인 반대의 경우를 생각해 보면 가용한 모든 데이터를 사용하여 반복을 실행할 수 있다.
    • 이를 배치 SGD (batch SGD)라고 한다. 더 정확하게 업데이트 되지만 더 많은 비용이 든다.
    • 극단적인 두 가지 방법의 효율적인 절충안은 적절한 크기의 미니 배치를 사용하는 것이다.
  • 그림 2-11은 1D 파라미터 공간에서 경사 하강법을 설명하고 있지만 실제로는 매우 고차원 공간에서 경사 하강법을 사용하게 된다.
    • 신경망에 있는 각각의 가중치 값은 이 공간에서 하나의 독립된 차원이고 수만 또는 수백만 개가 될 수도 있다.
    • 손실 함수의 표면을 좀 더 쉽게 이해하기 위해 아래 그림 2-12와 같이 2D 손실 함수의 표면을 따라 진행하는 경사 하강법을 시각화해 볼 수 있다.

  • 하지만 신경망이 훈련되는 실제 과정을 시각화하기는 어렵다. 4차원 이상의 공간을 사람이 이해할 수 있도록 표현하는 것이 불가능하기 때문이다.
    • 때문에 저차원 표현으로 얻은 직관이 실전과 항상 맞지 않는다는 것을 유념해야 한다. 이는 딥러닝 연구 분야에서 오랫동안 여러 이슈를 일으키는 근원이었다.
  • 또 업데이트할 다음 가중치를 계산할 때 현재 그래디언트 값만 보지 않고 이전에 업데이트도니 가중치를 여러 다른 방식으로 고려하는 SGD 변종이 많이 있다.
    • 예컨대 모멘텀을 사용한 SGD, Adagrad, RMSProp 등이 그것이다.
    • 이런 변종들은 모두 최적화 방법(optimization method) 또는 옵티마이저라고 부른다.
  • 특히 여러 변종들에서 사용하는 모멘텀(momentum) 개념은 아주 중요하다. 모멘텀은 SGD에 이는 2개의 문제점인 수렴 속도와 지역 최솟값을 해결한다.
    • 아래 그림 2-13은 네트워크의 파라미터 하나에 대한 손실 값의 곡선을 보여준다.

  • 어떤 파라미터 값에서는 지역 최솟값에 도달한다. 그 지점 근처에서는 왼쪽으로 이동해도 손실이 증가하고 오른쪽으로 이동해도 손실이 증가하기 때문이다.
    • 대상 파라미터가 작은 학습률을 가진 SGD로 최적화되었다면 최적화 과정이 전역 최솟값으로 향하지 못하고 이 지역 최솟값에서 갇히게 될 것이다.
  • 물리학에서 영감을 얻은 모멘텀을 사용하여 이 문제를 피할 수 있다. 여기에서 최적화 과정을 손실 곡선 위로 작은 공을 굴리는 것으로 생각하면 쉽게 이해할 수 있다.
    • 모멘텀이 충분함녀 공이 골짜기에 갇히지 않고 전역 최솟값에 도달할 것이다.
    • 모멘텀은 현재 기울기 값(현재 가속도) 뿐만 아니라 (과거의 가속도로 인한) 현재 속도를 함께 고려하여 각 단계에서 공을 움직인다.
    • 실전에 적용할 때는 현재 그래디언트 값 뿐만 아니라 이전에 업데이트한 파라미터에 기초하여 파라미터 w를 업데이트 한다.
  • 다음은 단순한 구현 예이다.
past_velocity = 0
momentum = 0.1 --모멘텀 상수
while loss > 0.01: --최적화 반복 루프
w, loss, gradient = get_current_parameters()
velocity = momentum * past_velocity - learning_rate * gradient
w = w + momentum * velocity - learning_rate * gradient
past_velocity = velocity
update_parameter(w)

변화율 연결: 역전파 알고리즘

  • 앞의 알고리즘에서 함수가 미분 가능하기 때문에 변화율을 직접 계산할 수 있다고 가정했다.
    • 실제로 신경망은 많은 텐서 연산으로 구성되어 있고 이 연산들의 변화율은 간단하며 이미 잘 알려져 있다.
    • 3개의 텐서 연산 a, b, c와 가중치 행렬 W1, W2, W3으로 구성된 네트워크 f를 예로 들어보자.
f(W1, W2, W3) = a(W1, b(W2, c(W3)))
  • 이벅분에서 이렇게 연결된 함수는 연쇄 법칙(chain rule)이라 부르는 다음 항등식 f(g(x))’ = f'(g(x)) * g'(x)를 사용하여 유도될 수 있다.
    • 연쇄 법칙을 신경망의 그래디언트를 계산에 적용하여 역전파(Backpropagation) 알고리즘(후진 모드 자동 미분(reverse-mode automatic differentiation)이라고도 부른다)이 탄생되었다.
    • 역전파는 최종 손실 값에서부터 시작한다.
    • 손실 값에 각 파라미터가 기여한 정도를 계산하기 위해 연쇄 법칙을 적용하여 최상위 층에서 하위층까지 거꾸로 진행된다.
  • 요즘에는 그리고 향후 몇 년 동안은 텐서플로처럼 기호 미분(symbolic differentiation)이 가능한 최신 프레임워크를 사용하여 신경망을 구현할 것이다.
    • 이 말은 변화율이 알려진 연산들로 연결되어 있으면 (연쇄 법칙을 적용하여) 네트워크 파라미터와 그래디언트 값을 매핑하는 그래디언트 함수를 계산할 수 있다는 의미이다.
    • 이런 함수를 사용하면 역방향 패스는 그래디언트 함수를 호출하는 것으로 단순화될 수 있다.
    • 기호 미분 덕택에 역전파 알고리즘을 직접 구현할 필요가 전혀 없고 정확한 역전파 공식을 유도하느라 시간과 노력을 소모하지 않아도 된다. 그래디언트 기반의 최적화가 어떻게 작동하는지 잘 이해하는 것만으로 충분하다.

첫 번째 예제 다시 살펴보기

  • 신경망의 이면에 어떤 원리가 있는지 기초적인 내용을 이해했으므로 첫 번째 예제로 돌아가서 이전 절에서 배웠던 내용을 이용하여 코드를 리뷰해 보자.
  • 먼저 입력 데이터이다.
(train_images, train_labels), (test_images, test_labels) = mnist.load_data()

train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype('float32') / 255

test_images = test_images.reshape((10000, 28 * 28))
test_images = test_images.astype('float32') / 255
  • 입력 이미지의 데이터 타입은 float32, 훈련 데이터는 (60000, 784) 크기, 테스트 데이터는 (10000, 784) 크기의 넘파이 배열로 저장된다.
  • 우리가 사용할 신경망이다.
network = models.Sequential()
network.add(layers.Dense(512, activation='relu', input_shape=(28 * 28,)))
network.add(layers.Dense(10, activation='softmax'))
  • 이 네트워크는 2개의 Dense 층이 연결되어 있고 각 층은 가중치 텐서를 포함하여 입력 데이터에 대한 몇 개의 간단한 텐서 연산을 적용한다.
    • 층의 속성인 가중치 텐서는 네트워크가 정보를 저장하는 곳이다.
  • 이제 네트워크를 컴파일 하는 단계이다.
network.compile(optimizer='rmsprop', loss='categorical_crossentropy', metrics=['accuracy'])
  • categorical_crossentropy는 손실 함수이다. 가중치 텐서를 학습하기 위한 피드백 신호로 사용되며 훈련하는 동안 최소화 된다.
    • 미니 배치 확률적 경사 하강법을 통해 손실이 감소된다.
    • 경사 하강법을 적용하는 구체적인 방식은 첫 번째 매개변수로 전달된 rmsprop 옵티마이저에 의해 결정된다.
  • 마지막으로 훈련 반복이다.
network.fit(train_images, train_labels, epochs=5, batch_size=128)
  • fit 메서드를 호출했을 때 다음과 같은 일이 일어난다.
    • 네크워크가 128개 샘플씩 미니 배치로 훈련 데이터를 다섯 번 반복한다(전체 훈련 데이터에 수행되는 각 반복을 에포크(epoch)라고 한다.)
    • 각 반복마다 네트워크가 배치에서 손실에 대한 가중치의 그래디언트를 계산하고 그에 맞추어 가중치를 업데이트 한다.
    • 다섯 번의 에포크 동안 네트워크는 2,345번의 그래디언트 업데이트를 수행할 것이다 (에포크마다 469번)

요약

  • 학습(Learning)은 훈련 데이터 샘플과 그에 상응하는 타깃이 주어졌을 때 손실 함수를 최소화 하는 모델 파라미터의 조합을 찾는 것을 의미한다.
  • 데이터 샘플과 타깃의 배치를 랜덤하게 뽑고 이 배치에서 손실에 대한 파라미터의 그래디언트를 계산함으로써 학습이 진행된다. 네트워크의 파라미터는 그래디언트의 반대 방향으로 조금씩(학습률에 의해 정의된 크기만큼) 움직인다.
  • 전체 학습 과정은 신경망이 미분 가능한 텐서 연산으로 연결되어 있기 때문에 간으하다. 현재 파라미터와 배치 데이터를 그래디언트 값에 매핑해주는 그래디언트 함수를 구성하기 위해 미분의 연쇄 법칙을 사용한다.
  • 이어지는 장에서 자주 보게 될 두 가지 핵심 개념은 손실과 옵티마이저이다. 이 두 가지는 네트워크에 데이터를 주입하기 전에 정의 되어야 한다.
  • 손실은 훈련하는 동안 최소화해야 할 양이므로 해결하는 문제의 성공을 측정하는데 사용한다.
  • 옵티마이저는 손실에 대한 그래디언트가 파라미터를 업데이트하는 정확한 방식을 정의한다. 예컨대 RMSProp 옵티마이저, 모멘텀을 사용한 SGD 등이다.

케라스 창시자에게 배우는 딥러닝/ 딥러닝이란 무엇인가?

인공 지능과 머신 러닝, 딥러닝

인공 지능

  • 인공 지능은 1950년대 초기 컴퓨터 과학 선각자들이 ‘컴퓨터가 생각할 수 있는가?’라는 질문을 하면서 시작되었음
  • 이 분야에 대한 간결한 정의는 ‘보통의 사람이 수행하는 지능적인 작업을 자동화하기 위한 연구활동’이다.
    • AI는 머신 러닝과 딥러닝을 포괄하는 종합적인 분야로 학습 과정이 전혀 없는 다른 방법도 많이 포함된다.
  • 오랜 기간 많은 전문가들이 프로그래머들이 명시적인 규칙을 충분히 많이 만들어 지식을 다루면 인간 수준의 인공 지능을 만들 수 있다고 믿었는데, 이런 접근 방법을 심볼릭 AI(Symbolic AI)라고 하며, 1950-1980년대까지 AI 분야의 지배적인 패러다임이었다.
    • 1980년대 전문가 시스템(expert system)의 호황으로 그 인기가 절정에 달했다.
  • 심볼릭 AI가 체스 처럼 잘 정의된 논리적인 문제를 푸는데는 적합하다는 것이 증명되었지만, 이미지 분류, 음성 인식, 언어 번역 같은 더 복잡하고 불분명한 문제를 해결하기 위한 명확한 규칙을 찾는 것은 아주 어려운 일이었다. 이런 심볼릭 AI를 대체하기 위한 새로운 방법이 바로 머신 러닝이다.

머신 러닝

  • 최초의 기계적 범용 컴퓨터인 해석기관(Analytical Engine)을 발명한 사람은 찰스 배비지(Charles Babbage)였다.
    • 범용 컴퓨터란 개념이 정의되지 않은 때였기 때문에 해석 기관은 해석학(mathematical analysis) 분야의 계산을 자동화 하기 위해 기계적인 연산을 사용하는 방법이었을 뿐이었다. 그래서 이름이 해석 기관이다.
  • 찰스 배비지의 친구자이 동료였던 –더불어 최초의 프로그래머로 인정받는– 에이다 러브레이스(Ada Lovelace)는 이 발명에 대해 다음과 같이 언급했다.
    • “해석 기관이 무언가를 새롭게 고안해 내는 것은 아니다. 우리가 어떤 것을 작동시키기 위해 어떻게 명령할지 알고 있다면 이 장치는 무엇이든 할 수 있다. … 이런 능력은 우리가 이미 알고 있는 것을 유용하게 사용할 수 있도록 도와줄 것이다”
  • AI의 선구자인 앨런 튜링(Alan Turing)은 그의 기념비적인 논문 “Computing Machinery and Intelligence”에서 ‘러브레이스의 반론(Lady Lovelace’s objection)’으로 이 논평을 인용했다.
    • 튜링은 에이다 러브레이스의 말을 인용했지만 범용 컴퓨터가 학습과 창의력을 가질 수 있는지에 대해 가능한 일이라고 결론을 냈다.
  • 머신 러닝은 이런 질문에서 시작된다.
    • 우리가 어떤 것을 작동시키기 위해 ‘어떻게 명령할지 알고 있는 것’ 이상을 컴퓨터가 처리하는 것이 가능한가?
    • 특정 작업을 수행하는 법을 스스로 학습할 수 있는가?
    • 컴퓨터가 우리를 놀라게 할 수 있을까?
    • 프로그래머가 직접 만든 데이터 처리 규칙 대신 컴퓨터가 데이터를 보고 자동으로 이런 규칙을 학습할 수 있을까?
  • 이 질문은 새로운 프로그래밍 패러다임의 장을 열었다. 전통적인 프로그래밍인 심볼릭 AI의 패러다임에서는 규칙과 이 규칙에 따라 처리될 데이터를 입력하면 해답이 출력된다.
    • 머신 러닝에서는 데이터와 이 데이터로부터 기대되는 해답을 입력하면 규칙이 출력된다. 이 규칙을 새로운 데이터에 적용하여 창의적인 답을 만들 수 있다.

  • 머신 러닝 시스템은 명시적으로 프로그램 되는 것이 아니라 훈련(training) 된다.
    • 작업과 관련 있는 많은 샘플을 제공하면 이 데이터에서 통계적 구조를 찾아 그 작업을 자동화하기 위한 규칙을 만들어낸다.
  • 머신 러닝은 1990년대 들어서야 각광을 받기 시작했지만, 고성능 하드웨어와 대량의 데이터셋이 가능해지면서 AI에서 가장 인기 있고 성공적인 분야가 되었다.
  • 머신 러닝은 수리 통계와 밀접하게 관련되어 있지만 통계와 다른 점이 몇 가지 있다.
    • 먼저 머신 러닝은 통계와 달리 보통 대량의 복잡한 데이터셋을 다루기 때문에 베이지안 분석(Bayesian analysis) 같은 전통적인 통계 분석 방법은 현실적으로 적용하기 어렵다.
    • 이런 이유로 머신 러닝, 특히 딥러닝은 수학적 이론이 비교적 부족하고 엔지니어링 지향적이다.
    • 이런 실천적인 접근 방식 때문에 이론보다 경험을 바탕으로 아이디어가 증명되는 경우가 많다.

데이터에서 표현을 학습하기

  • 머신 러닝은 샘플과 기댓값이 주어졌을 때 데이터 처리 작업을 위한 실행 규칙을 찾는 것이다. 머신 러닝을 하기 위해서는 세 가지가 필요하다.
    • 입력 데이터 포인트 –음성 파일, 이미지 파일 등
    • 기대 출력 –강아지, 고양이 등
    • 알고리즘의 성능을 측정하는 방법 –알고리즘의 현재 출력과 기대 출력 간의 차이를 결정하기 위해 필요. 측정 값은 알고리즘의 작동 방식을 교정하기 위한 신호로 다시 피드백되는데, 이런 수정 단계를 학습(learning)이라고 한다.
  • 머신 러닝 모델은 입력 데이터를 의미 있는 출력으로 변환한다. 이것이 알고 있는 입력과 출력의 샘플로부터 학습하는 과정이다. 그렇기 때문에 머신 러닝과 딥러닝의 핵심 문제는 의미있는 데이터로의 변환이다.
    • 다시 말해 입력 데이터를 기반으로 기대 출력에 가깝게 만드는 유용한 표현(representation)을 학습하는 것이다.
    • 여기서 표현이란 데이터를 인코딩(encoding)하거나 묘사하기 위해 데이터를 바라보는 다른 방법이다. 예컨대 컬러 이미지는 RGB, HSV 포맷으로 인코딩될 수 있는데, 이들은 같은 데이터의 다른 표현이다.
    • 어떤 표현으로는 해결하기 힘든 문제가 다른 표현으로는 쉽게 해결될 수 있다. 예컨대 이미지에서 빨간색 픽셀을 선택하는 것은 RGB 포맷이 쉽고, 이미지의 채도를 낮추는 것은 HSV 포맷이 더 쉽다.
    • 머신 러닝 모델은 입력 데이터에서 적절한 표현을 찾는 것이다. 이런 데이터 변환은 분류 작업 같은 문제를 더 쉽게 해결할 수 있도록 만들어 준다.
  • 예들 들어보자. x, y축이 있고 이 x, y 좌표 시스템으로 표현된 데이터 포인트가 아래 그림과 같다고 하자.

  • 위 그림에는 흰색점과 빨간색점이 존재하는데, 포인트의 좌표 (x, y)를 입력으로 받고 그 포인트가 빨간색인지 흰색인지를 출력하는 알고리즘을 개발한다고 하자. 이 경우는 다음과 같이 요약할 수 있다.
    • 입력은 점의 좌표이다.
    • 기대 출력은 점의 색깔이다.
    • 알고리즘의 성능을 측정하는 방법은 정확히 분류한 포인트의 비율을 사용하여 알고리즘의 성능을 측정한다.
  • 여기서 우리가 원하는 것은 흰색점과 빨간색점을 완벽하게 구분하는 새로운 데이터표현이다. 사용할 수 있는 변환 방법 중 하나는 아래 그림과 같은 좌표 변환이다.

  • 포인트에 대한 새로운 좌표는 새로운 데이터 표현이라고 할 수 있다. 그리고 좋은 표현을 찾았다.
    • 이 표현을 사용하면 색깔 분류 문제를 “x > 0 인 것은 빨간색 점이다”와 같은 간단한 규칙으로 나타낼 수 있다.
    • 기본적으로 이 분류 문제를 해결한 것은 새로운 표현이다.
  • 이 경우 우리가 직접 좌표 변환을 정했는데, 만약 시스템적으로 가능한 여러 좌표 변환을 찾아서 포인트 중 몇 퍼센트가 정확히 분류되었는지를 피드백으로 사용한다면, 바로 머신 러닝을 하고 있는 것이다.
    • 이처럼 머신 러닝에서의 학습(Learning)이란 더 나은 표현을 찾는 자동화된 과정이다.
  • 모든 머신 러닝 알고리즘은 주어진 작업을 위해 데이터를 더 유용한 표현으로 바꾸는 이런 변환을 자동으로 찾는다.
    • 이 연산은 앞서 본 좌표 변환일 수도 있고 또는 선형 투영(linear projection), 이동(translation), 비선형 연산 등이 될 수 있다.
    • 머신 러닝 알고리즘은 일반적으로 이런 변환을 찾기 위한 창의력은 없다. 가설 공간(hypothesis space)이라 부르는 미리 정의된 연산의 모음들을 자세히 조사하는 것 뿐이다.
  • 기술적으로 말하면 머신 러닝은 가능성 있는 공간을 사전에 정의하고 피드백 신호의 도움을 받아 입력 데이터에 대한 유용한 변환을 찾는 것이다.
    • 이 간단한 아이디어가 음성 인식에서부터 자율 주행 자동차까지 아주 다양한 분야에서 지능에 관한 문제를 해결한다.

딥러닝에서 ‘딥’이란 무엇일까?

  • 딥러닝은 머신 러닝의 특정한 한 분야로서 연속된 층(layer)에서 점진적으로 의미 있는 표현을 배우는데 강점이 있으며, 데이터로부터 표현을 학습하는 새로운 방식이다.
    • 딥러닝의 딥(deep)이란 단어는 연속된 층으로 표현을 학습한다는 개념을 나타낸다.
    • 데이터로부터 모델을 만드는데 얼마나 많은 층을 사용했는지가 그 모델의 깊이가 된다.
    • 이 분야에 대한 적절한 다른 이름은 층 기반 표현 학습(layered representations learning) 또는 계층적 표현 학습(hierarchical representations learning)이 될 수 있다.
    • 최근 딥러닝 모델은 표현 학습을 위해 수십 개, 수백 개의 연속된 층을 갖고 있다. 이 층들을 모두 훈련 데이터에 노출해서 자동으로 학습시킨다.
    • 한편 다른 머신 러닝 접근 방법은 1-2개의 데이터 표현 층을 학습하는 경향이 있다. 그래서 이런 방식을 얕은 학습(shallow learning)이라 부르기도 한다.
  • 딥러닝에서는 기본 층을 겹겹이 쌓아 올려 구성한 신경망(neural network)이라는 모델을 사용하여 표현 층을 학습한다.
    • 신경망이란 단어는 신경 생물학의 용어로 딥러닝의 일부 핵심 개념이 뇌 구조를 이해하는 것에서 영감을 얻어 개발된 부분이 있지만, 딥러닝 모델이 뇌를 모델링한 것은 아니다.
    • 최근의 딥러닝 모델이 사용하는 학습 메커니즘과 유사한 것을 뇌가 가지고 있다는 근거는 없다. 대중 과학 저널에서 딥러닝이 뇌처럼 작동한다거나 뇌를 모방하여 만들었다고 주장하는 그은 사실이 아니다.
    • 딥러닝은 그냥 데이터로부터 표현을 학습하는 수학 모델일 뿐이다.
  • 딥러닝 알고리즘으로 학습된 표현은 어떻게 나타나는지 예를 들어보자. 몇 개의 층으로 이루어진 네트워크가 이미지 안의 숫자를 인식하기 위해 이미지를 어떻게 변환하는지 살펴보자.

  • 아래 그림에서 볼 수 있듯 최종 출력에 대해 점점 더 많은 정보를 가지지만 원본 이미지와는 점점 더 다른 표현으로 숫자 이미지가 변환된다.
    • 심층 신경망을 정보가 연속된 필터(filter)를 통과하면서 순도 높게 정제되는 다단계 정보 추출 작업으로 생각할 수 있다.

  • 바로 이것이 딥러닝이다. 기술적으로는 데이터 표현을 학습하기 위한 다단계 처리 방식을 말한다.

그림 3개로 딥러닝의 작동 원리 이해하기

  • 머신 러닝이 많은 입력과 타깃(target)의 샘플을 관찰하면서 입력을 타깃에 매핑(mapping) 하는 것임을 알았다.
    • 심층 신경망은 이런 입력-타깃 매핑을 간단한 데이터 변환기(층)를 많이 연결하여 수행한다는 것도 배웠다. 이런 데이터 변환은 샘플에 노출됨으로써 학습이 이루어진다.
  • 층에서 입력 데이터가 처리되는 상세 내용은 일련의 숫자로 이루어진 층의 가중치(weight)에 저장되어 있다.
    • 기술적으로 말하면 어떤 층에서 일어나는 변환은 그 층의 가중치를 파라미터(parameter)로 가지는 함수로 표현된다. (이따금 가중치를 그 층의 파라미터라고도 부른다)
    • 이런 맥락으로 보면 학습은 주어진 입력을 정확한 타깃에 매핑하기 위해 신경마으이 모든 층에 있는 가중치 값을 찾는 것을 의미한다.
    • 하지만 어떤 심층 신경망은 수천만 개의 파라미터를 가지기도 한다.
    • 이런 경우 모든 파라미터의 정확한 값을 찾는 것은 어려운 일로 보인다. 파라미터 하나의 값을 바꾸면 다른 모든 파라미터에 영향을 끼치기 때문이다.

  • 어떤 것을 조정하려면 먼저 관찰해야 한다. 신경망의 출력을 제어하려면 출력이 기대하는 것보다 얼마나 벗어났는지를 측정해야 한다.
    • 이는 신경망의 손실 함수(loss function) 또는 목적 함수(objective function)가 담당하는 일이다.
    • 신경망이 한 샘플에 대해 얼마나 잘 예측했는지 측정하기 위해 손실 함수가 신경망의 예측과 진짜 타깃의 차이를 점수로 계산한다. (아래 그림 참고)

  • 기본적인 딥러닝 방식은 이 점수를 피드백 신호로 사용하여 현재 샘플의 손실 점수가 감소되는 방향으로 가중치 값을 조금씩 수정하는 것이다.
    • 이런 수렴 과정은 딥러닝의 핵심 알고리즘인 역전파(Backpropagation) 알고리즘을 구현한 옵티마이저(optimizer)가 담당한다.

  • 초기에는 네트워크의 가중치가 랜덤한 값으로 할당되므로 랜덤한 변환을 연속적으로 수행한다.
    • 자연스럽게 출력은 기대한 것과 멀어지고 손실 점수가 높을 것이지만 네트워크가 모든 샘플을 처리하면서 가중치가 조금씩 올바른 방향으로 조정되고 손실 점수가 감소한다.
    • 이를 훈련 반복(training loop)이라고 하며, 충분한 횟수만큼 반복하면 손실 함수를 최소화하는 가중치 값을 산출한다.
    • 최소한의 손실을 내는 네트워크가 타깃에 가능한 가장 가까운 출력을 만든느 모델이 된다.

지금까지 딥러닝의 성과

  • 딥러닝은 머신 러닝의 오래된 하위 분야지만 2010년 초가 되서야 유명해졌다. 이때부터 몇 년 동안 이 분야에서 일어난 혁신은 결코 작지 않다.
    • 기계가 오랫동안 해결하기 어려웠던 시각과 청각 같은 지각의 문제에서 괄목할만한 성과를 냈기 때문이다.
    • (이하 성과 사례 생략)

단기간의 과대 선전을 믿지 말자

  • 딥러닝이 최근 몇 년간 놀라운 성과를 냈지만, 사람들이 향후 10년 안에 기대하는 성과는 가능한 것보다 훨씬 높다.
    • 자율 주행 같은 몇 가지 애플리케이션은 이미 가까이 다가왔지만, 신뢰할 만한 대화 시스템이나 사람 수준의 기계 번역 그리고 사람 수준의 자연어 이해처럼 더 많은 것이 오랫동안 어려운 문제로 남아 있을 것 같다.
    • 특히 사람 수준의 일반 지능(general intelligence)에 관한 이야기는 너무 심각하게 다루지 않는 것이 좋다.
  • 과거에 AI는 장미빛 전망 뒤에 이어진 실망과 회의의 사이클을 두 번이나 경험했다.
  • 첫번째는 1960년대 심볼릭 AI였는데, 심볼릭 AI 방법에 관한 가장 유명한 개척자이지 지지자 중 한 명인 마빈 민스키(Marvin Minsky)는 1967년에 “이번 세대 안에 … ‘인공 지능’을 만드는 문제는 거의 해결될 것이다”라고 주장했다.
    • 몇 년 후 이런 높은 기대가 구체화되지 못하자 연구자들과 정부 자금은 투자를 줄였고 첫 번째 AI 겨울이 시작되었다. (이는 냉전이 절정에 다다른 후에 나타난 핵 겨울(nuclear winter)에 대한 비유이다)
  • 1980년대 심볼릭 AI의 새로운 버전인 전문가 시스템(expert system)이 큰 기업들 사이에 인기를 끌기 시작해서 전 세계 회사들이 전문가 시스템을 구축하기 위해 내부에 AI 부서를 꾸리기도 했다.
    • 1990년대 초기 이 시스템은 유지 비용이 비싸고 확장하기 어려우며 제한된 범위를 가진다는 것이 증명되고 관심은 사그라들었다. 이로 인해 두 번째 AI겨울이 시작되었다.

AI에 대한 전망

  • AI에 대한 단기간의 기대는 비현실적일지 모르지만 장기간의 전망은 매우 밝다.
    • (이하 설명 생략)

딥러닝 이전: 머신 러닝의 간략한 역사

확률적 모델링

  • 확률적 모델링(probabilistic modeling)은 통계학 이론을 데이터 분석에 응요한 것으로 초기 머신 러닝 형태 중 하나고 요즘도 널리 사용된다. 가장 잘 알려진 알고리즘 중 하나는 나이브 베이즈(Naive Bayes) 알고리즘이다.
  • 나이브 베이즈는 입력 데이터의 특성이 모두 독립적이라고 가정하고 베이즈 정리를 적용하는 머신 러닝 분류 알고리즘이다.
    • 이런 형태의 데이터 분석은 컴퓨터 보다 먼저 있었기 때문에 컴퓨터가 등장하기 전에는 수작업으로 적용했다. (거의 1950년대로 거슬러 올라간다)
    • 베이즈 정리와 통계의 토대는 18세기까지 올라간다.
  • 이와 밀접하게 연관된 모델이 로지스틱 회귀(logistic regression)인데, 이 모델은 현대 머신 러닝의 “hello world”로 여겨진다.
    • 이름 때문에 헷갈리지만 로지스틱 회귀는 회귀(regression) 알고리즘이 아니라 분류(classification) 알고리즘이다.
    • 나이브 베이즈와 비슷하게 로지스틱 회귀도 컴퓨터 보다 훨씬 오래 전에 있었다.
    • 하지만 간단하고 다목적으로 활용할 수 있어서 오늘날에도 유용하다.
    • 데이터 과학자가 분류 작업에 대한 감을 빠르게 얻기 위해 데이터셋에 적용할 첫 번째 알고리즘으로 선택하는 경우가 많다.

초창기 신경망

  • 초창기 버전의 신경망은 이 책에서 다루는 최신 구조로 완전히 대체되었다.
    • 신경망의 핵심 아이디어는 1950년대 연구 되었으나 본격적으로 시작되기까지는 수십 년이 걸렸다.
    • 대규모 신경망을 훈련시킬 수 있는 효과적인 방법을 오랜 기간 동안 찾지 못했기 때문이다.
  • 1980년대 중반에 여러 사람들이 제각기 역전파 알고리즘을 재발견하고 신경망에 적용하기 시작하면서 상황이 바뀌었다.
    • 이 알고리즘은 경사 하강법 최적화를 사용하여 연쇄적으로 변수가 연결된 연산을 훈련하는 방법이다.
  • 성공적인 첫 번째 신경망 애플리케이션은 1989년 벨 연구소에서 나왔다.
    • 얀 르쿤(Yann LeCun)은 초창기 합성곱 신경망(convolution nerual network)과 역전파를 연결하여 손글씨 숫자 이미지를 분류하는 문제에 적용했다.
    • LaNet 이라 부르는 이 신경망은 우편 봉투의 우편 번호 코드를 자동으로 읽기 위해 1990년대 미국 우편 서비스에 사용 되었다.

커널 방법

  • 초기 성공에 힘입어 1990년대 신경망은 연구자들 사이에 관심을 얻기 시작했지만 머신 러닝의 새로운 접근 방법인 커널 방법이 인기를 얻자 신경망은 빠르게 잊혀졌다.
  • 커널 방법(Kernel method)는 분류 알고리즘의 한 종류이며, 그중 서포트 벡터 머신(Support Vector Machine, SVM)이 가장 유명하다.
    • 현대적인 SVM의 공식은 1990년대 초 벨 연구소의 블라드미르 바프닉(Vladmir Vapnik)과 코리나 코르테스(Corinna Cortes)에 의해 개발되었고 1995년에 공개되었다.
    • 바프닉과 알렉세이 체르보넨키스(Alexey Chervonenkis)가 만든 오래된 선형 공식은 1963년에 공개되었다.
  • SVM은 분류 문제를 해결하기 위해 2개의 다른 범주에 속한 데이터 포인트 그룹 사이에 좋은 결정 경계(decision boundary)를 찾는다.
    • 결정 경계는 훈련 데이터를 2개의 범주에 대응하는 영역으로 나누는 직선이나 표면으로 생각할 수 있다.
    • 새로운 데이터 포인트를 분류하려면 결정 경계 어느 쪽에 속하는지를 확인하기만 하면 된다.

  • SVM이 결정 경계를 찾는 과정은 두 단계이다.
    1. 결정 경계가 하나의 초평면(hyperplane)으로 표현될 수 있는 새로운 고차원 표현으로 데이터를 매핑한다. (위 그림과 같은 2차원 데이터라면 초평면은 직선이 된다)
    2. 초평면과 각 클래스의 가장 가까운 데이터 포인트 사이의 거리가 최대가 되는 최선의 결정 경계(하나의 분할 초평면)를 찾는다. 이 단계를 마진 최대화(maximizing the margin)라고 부른다. 이렇게 함으로써 결정 경계가 훈련 데이터셋 이외의 새로운 샘플에 잘 일반화 되도록 도와준다.
  • 분류 문제를 간단하게 만들어 주기 위해 데이터를 고차원 표현으로 매핑하는 기법이 이론상으로는 좋아보이지만 실제로는 컴퓨터로 구현하기 어려운 경우가 많다. 그래서 커널 기법(kernel trick)이 등장했다.
    • 요지는 다음과 같다. 새롭게 표현된 공간에서 좋은 결정 초평면을 찾기 위해 새로운 공간에 대응하는 데이터 포인트의 좌표를 실제로 구할 필요가 없다.
    • 새로운 공간에서의 두 데이터 포인트 사이의 거리를 계산할 수만 있으면 된다. 커널 함수(kernel function)를 사용하면 이를 효율적으로 계산할 수 있다.
    • 커널 함수는 원본 공간에 있는 두 데이터 포인트를 명시적으로 새로운 표현으로 변환하지 않고 타깃 표현 공간에 위치했을 떄의 거리를 매핑해 주는 계산 가능한 연산이다.
    • 커널 함수는 일반적으로 데이터로부터 학습되지 않고 직접 만들어야 한다. SVM에서 학습되는 것은 분할 초평면뿐이다.
  • SVM이 개발되었을 때 간단한 분류 문제에 대해 최고 수준의 성능을 달성했고 광범위한 이론으로 무장된 몇 안되는 머신 러닝 방법 중 하나가 되었다.
    • 또 수학적으로 깊게 분석하기 용이하여 이론을 이해하고 설명하기 쉽다.
    • 이런 유용한 특징 때문에 SVM이 오랫동안 머신 러닝 분야에서 매우 큰 인기를 끌었다.
  • 하지만 SVM은 대용량의 데이터셋에 확자오디기 어렵고 이미지 분류 같은 지각에 관련된 문제에서 좋은 성능을 내지 못했다.
    • SVM은 얕은 학습 방법이기 때문에 지각에 관련된 문제에 SVM을 적용하려면 먼저 수동으로 유용한 표현을 추출해야 하는데 (이런 단계를 특성 공학(feature engineering)이라고 한다) 이는 매우 어렵고 불안정하다.

결정 트리, 랜덤 포레스트, 그래디언트 부스팅 머신

  • 결정 트리 (decision tree)는 플로차트(flowchart) 같은 구조를 가지며 입력 데이터 포인트를 분류하거나 주어진 입력에 대해 출력 값을 예측한다.
    • 결정 트리는 시가고하하고 이해하기 쉽다.
    • 데이터에서 학습되는 결정 트리는 2000년대부터 연구자들에게 크기 관심을 받기 시작했고 2010년까지는 커널 방법보다 선호하곤 했다.

  • 특히 랜덤 포레스트(Random Forest) 알고리즘은 결정 트리 학습에 기초한 것으로 안정적이고 실전에서 유용하다.
    • 서로 다른 결정 트리를 많이 만들고 그 출력을 앙상블 하는 방법을 사용한다.
    • 랜덤 포레스트는 다양한 문제에 적용할 수 있다. 얕은 학습에 해당하는 어떤 작업에서도 거의 항상 두 번째로 가장 좋은 알고리즘이다.
    • 머신 러닝 경연 웹 사이트인 캐글(Kaggle)이 2010년에 시작되었을 때부터 랜덤 포레스트가 가장 선호하는 알고리즘이 되었다.
  • 2014년에 그래디언트 부스팅 머신(gradient boosting machine)이 그 뒤를 이어 받았다.
    • 랜덤 포레스트와 아주 비슷하게 그래디언트 부스팅 머신은 약한 예측 모델인 결정 트리를 앙상블 하는 것을 기반으로 하는 머신 러닝 기법이다.
    • 이 알고리즘은 이전 모델에서 놓친 데이터 포인트를 보완하는 새로운 모델을 반복적으로 훈련함으로써 머신 러닝 모델을 향상하는 방법인 그래디언트 부스팅(gradient boosting)을 사용한다.
  • 결정 트리에 그래디언트 부스팅 기법을 적용하면 비슷한 성질을 가지면서도 대부분의 경우에 랜덤 포레스트의 성능을 능가하는 모델을 만든다.
    • 이 알고리즘이 오늘날 지각에 관련되지 않은 데이터를 다루기 위한 알고리즘 중 최고는 아니지만 가장 뛰어나다.
    • 딥러닝을 제외하고 캐글 경연 대회에서 가장 많이 사용되는 기법이다.

다시 신경망으로

  • 2010년경 신경망은 관심을 받지 못했지만, 여전히 신경망에 대해 연구하고 있던 일부 사람들이 중요한 성과를 내기 시작했다.
    • 토론토 대학의 제프리 힌튼(Geoffrey Hinton), 몬트리올 대학의 요슈아 벤지오(Yoshua Bengio), 뉴욕 대학의 얀 르쿤, 스위스의 IDSIA이다.
  • 2011년에 IDSIA의 댄 크리슨(Dan Ciresan)이 GPU로 훈련된 심층 신경망(depp neural network)으로 학술 이미지 분류 대회에서 우승한 것이 시작이었다. 이것이 현대적인 딥러닝의 첫 번째 성공이다.
    • 이어서 2012년 대규모 이미지 분류 대회인 ImageNet에서 힌튼 팀이 등장하면서 분수령이 되었다.
    • ImageNet 대회는 당시 아주 어려운 문제로 전통적인 컴퓨터 비전 방식을 사용한 우승 모델의 상위 5개 예측이 타깃 클래스를 맞출 정확도는 74.3%였는데, 제프리 힌튼이 조언자로 참여하고 알렉스 크리체브스키(Alex Krizhevsky)가 이끄는 팀이 상위 5개 예측에 대한 정확도 83.6%의 놀라운 성과를 달성했다.
  • 이때부터 매년 이 대회는 심층 합성곱 신경망(deep convolutional nerual networ, ConvNet)이 우승을 차지했다.
    • 2015년 우승자는 96.4%의 정확도를 달성했고 ImageNet의 분류 문제는 완전히 해결된 것으로 간주되었다.
  • 2012년부터 심층 합성곱 신경망이 모든 컴퓨터 비전 작업의 주력 알고리즘이 되었다. 이 알고리즘은 조금 더 일반적이며, 지각에 관한 모든 문제에 적용할 수 있다.
    • 2015년과 2016년에 열린 주요 컴퓨터 비전 컨퍼런스에서 어떤 형태로든 컨브넷(ConvNet)을 포함하지 않은 발표를 찾는 것은 거의 불가능할 정도이다.
    • 동시에 딥러닝은 자연어 처리(natural language processing) 같은 다른 종류의 문제에도 적용되었다.
  • 다양한 애플리케이션에서 SVM과 결정 트리를 완전히 대체하고 있다.
    • 예컨대 지난 몇 년간 유럽 입자 물리 연구소(European Organization for Nuclear Research, CERN)는 대형 강입자 충돌기(Large Hadron Collider, LHC)에 있는 ATALS 감지기에서 얻은 입자 데이터를 분석하기 위해 결정 트리 기반의 알고리즘을 사용했으나 최근 연구는 케라스(Keras) 기반의 심층 신경망을 적용하기 시작했다.

딥러닝의 특징

  • 딥러닝이 이렇게 빠르게 확산된 주된 이유는 많은 문제에서 더 좋은 성능을 내고 있기 때문이다.
    • 하지만 그것뿐만이 아닌데, 딥러닝은 머신 러닝에서 가장 중요한 단계인 특성 공학을 완전히 자동화 하기 때문에 문제를 더 해결하기 쉽게 만들어준다.
  • 얕은 학습인 이전의 머신 러닝 기법은 입력 데이터를 고차원 비선형 투영(SVM)이나 결정 트리 같은 간단한 변환을 통해 하나 또는 2개의 연속된 표현 공간으로만 변환한다. 하지만 복잡한 문제에 필요한 잘 정제된 표현은 일반적으로 이런 방식으로 얻지 못한다.
    • 이런 머신 러닝 방법들로 처리하기 용이하게 사람이 초기 입력 데이터를 여러 방식으로 변환해야 한다. 즉 데이터의 좋은 표현을 수동으로 만들어야 하는데, 이를 특성 공학(feature engineering)이라고 한다.
  • 이에 반해 딥러닝은 이 단계를 완전히 자동화 한다. 딥러닝을 사용하면 특성을 직접 찾는 대신 한 번에 모든 특성을 학습할 수 있다.
    • 머신 러닝 작업 흐름을 매우 단순화시켜 주므로 고도의 다단계 작업 과정을 하나의 간단한 end-to-end 딥러닝 모델로 대체할 수 있다.
  • 이슈의 핵심이 여러 개의 연속된 표현 층을 가지는 것이라면, 얕은 학습 방법도 딥러닝의 효과를 모사하기 위해 반복적으로 적용할 수 있지 않을까 하는 의문이 들 수 있다.
    • 실제로 얕은 학습 방법을 연속적으로 적용하면 각 츠으이 효과는 빠르게 줄어든다. 3개의 층을 가진 모델에서 최정의 첫 번째 표현 층은 하나의 층이나 2개의 층을 가진 모델에서 최정의 첫 번째 층과는 달라야 한다.
  • 딥러닝의 변환 능력은 모델이 모든 표현 층을 순차적이 아니라(탐욕적(greedily) 방법이 아니라) 동시에 공동으로 학습하게 만든다.
    • 이런 공동 특성 학습 능력 덕택에 모델이 내부 특성 하나에 맞춰질 때마다 이에 의존하는 다른 모든 특성이 사람이 개입하지 않아도 자동으로 변화에 적응하게 된다.
    • 모든 학습은 하나의 피드백 신호에 의해 시작된다. 즉 모델의 모든 변화는 최종 목표를 따라가게 된다.
    • 이 방식은 모델을 많은 중간 영역(층)으로 나누어 복잡하고 추상화된 표현을 학습시킬 수 있기 때문에 얕은 학습 모델을 탐욕적으로 쌓은 것보다 훨씬 강력하다. 여기에 각 층은 이전 층의 의존하지 않는 단순한 변환을 수행한다.
  • 딥러닝이 데이터로부터 학습하는 방법에는 두 가지 중요한 특징이 있다.
    • 층을 거치면서 점진적으로 더 복잡한 표현이 만들어진다는 것과 이런 점진적인 중간 표현이 공동으로 학습된다는 사실이다.
    • 각 층은 상위 층과 하위 층의 표현이 변함에 따라 함께 바뀐다. 이 2개의 특징이 이전의 머신 러닝 접근 방법보다 딥러닝이 훨씬 성공하게 된 이유이다.

머신 러닝의 최근 동향

  • (앞선 내용 생략)
  • 오늘날 머신 러닝을 성공적으로 적용하기 위해 알아야 할 두 가지 기술은 얕은 학습 문제를 위한 그래디언트 부스팅 머신과 지각에 관한 문제를 위한 딥러닝이다.

왜 딥러닝일까? 왜 지금일까?

  • 컴퓨터 비전에 대한 딥러닝의 두 가지 핵심 아이디어인 합성곱 신경망과 역전파는 이미 1989년에 소개되었다.
    • 시계열을 위한 딥러닝의 기본인 LSTM(Long Short-Term Memory) 알고리즘은 1997년에 개발되었고 그 이후로 변화가 거의 없다.
  • 왜 2012년 이후 딥러닝이 부상하게 되었을까? 일반적으로 세 가지 기술적인 힘이 머신 러닝의 진보를 이끌었다.
    • 하드웨어
    • 데이터셋과 벤치마크
    • 알고리즘 향상
  • 이 분야는 이론보다 실험을 통해서 성장해 왔기 때문에 새로운 아이디어를 실험할 적절한 데이터와 하드웨어가 준비되어 있어야만 알고리즘이 발전할 수 있다.
    • 머신 러닝은 수학이나 물리학이 아니라 하나의 공학이다.

하드웨어

  • (GPU가 등장했다는 설명 생략)

데이터

  • (데이터가 많아졌다는 설명 생략)

알고리즘

  • 2000년대 후반까지는 매우 깊은 심층 신경망을 훈련시킬 수 있는 안정적인 방법을 찾지 못했는데, 이런 이유로 하나 또는 2개의 층만 사용하는 매우 얕은 신경망만 가능했고 SVM과 랜덤 포레스트처럼 잘 훈련된 얕은 학습 방법에 비해 크게 빛을 보지 못했다.
  • 깊게 쌓은 층을 통과해서 그래디언트(gradient)를 전파하는 것이 가장 문제였는데, 신경망을 훈련하기 위한 피드백 신호가 층이 늘어남에 따라 희미해지기 때문이다.
  • 2009-2010년경 몇 가지 간단하지만 중요한 알고리즘이 개선되면서 그래디언트를 더 잘 전파되게 만들어 주었고 상황이 바뀌었다.
    • 신경망의 층에 더 잘 맞는 활성화 함수(activation function)
    • 층별 사전 훈련(pretraining)을 불필요하게 만든 가중치 초기화(weight initialization) 방법
    • RMSProp과 Adam 같은 더 좋은 최적화 방법
  • 이런 기술의 향상으로 10개 이상의 층을 가진 모델을 훈련시킬 수 있게 되었을 때 비로소 딥러닝이 빛을 발하기 시작했다.
  • 2014-2016년 사이에 그래디언트를 더욱 잘 전파할 수 있는 배치 정규화(batch normalization), 잔차 연결(residual connection), 깊이별 분리 합성곱(depthwise separable convolution) 같은 고급 기술들이 개발된 덕에 요즘에는 층의 깊이가 수천 개인 모델을 처음부터 훈련시킬 수 있다.

새로운 투자의 바람

  • (생략)

딥러닝의 대중화

  • (생략)

지속될까?

  • (생략)

머신 러닝 교과서/ 순환 신경망으로 시퀀스 데이터 모델링

시퀀스 데이터 소개

시퀀스 데이터 모델링: 순서를 고려한다

  • 다른 데이터 타입과 다르게 시퀀스는 특별하다. 시퀀스 원소들은 특정 순서를 가지므로 상호 독립적이지 않기 때문이다.
  • 일반적으로 지도 학습의 머신 러닝 알고리즘은 입력 데이터가 독립 동일 분포(Independent and Identically Distributed, IID)라고 가정한다.
    • 예컨대 n 개의 데이터 샘플 x^{(1)}, x^{(2)}, ... , x^{(n)} 이 있을 떄 머신 러닝 알고리즘을 훈련하기 위해 데이터를 사용하는 순서는 상관없다.
    • 시쿠너스 데이터를 다룰 때는 이런 가정이 유효하지 않다. 시퀀스는 정의 자체가 순서를 고려한 데이터이기 때문이다.

시퀀스 표현

  • 입력 데이터에서 의미 있는 순서를 가지도록 시퀀스를 구성한다. 그 다음 머신 러닝 모델이 이런 유용한 정보를 사용하도록 만들어야 한다.
  • 이 장에서는 시퀀스를 x^{(1)}, x^{(2)}, ... , x^{(T)} 처럼 나타내겠다. 위 첨자는 샘플 순서이고 T 는 시쿠너스 길이이다.
    • 시퀀스의 좋은 예시는 시계열 데이터이다. 여기서 각 샘플 포인트 x^{(t)} 는 특정 시간 t 에 속한다.
  • 아래 그림은 시계열 데이터 예를 보여준다. x y 는 시간축을 따라 순서대로 나열되어 있다. 따라서 x y 는 시퀀스 데이터이다.

  • MLP와 CNN 같이 지금까지 다룬 기본적인 신경망 모델은 입력 샘플의 순서를 다루지 못한다. 쉽게 생각해서 이런 모델은 이전에 본 샘플을 기억하지 못한다.
    • 샘플이 정방향과 역방향 단계를 통과하고 가중치는 샘플이 처리되는 순서와 상관없이 업데이트 된다.
  • 반면 RNN은 시쿠너스 모델링을 위해 고안되었다. 지난 정보를 기억하고 이를 기반으로 새로운 이벤트를 처리할 수 있다.

시퀀스 모델링의 종류

  • 시퀀스 모델링은 언어 번역, 이미지 캡셔닝, 텍스트 생성처럼 흥미로운 애플리케이션이 많이 있다.
  • 적절한 모델을 개발하기 위해 시퀀스 모델링의 종류를 이해할 필요가 있다. 아래 그림은 각기 다른 종류의 입력과 출력 데이터에 대한 관계를 보여준다.

  • 어떤 입력 데이터와 출력 데이터가 있다고 가정하자. 입력과 출력 데이터가 시퀀스로 표현되지 않는다면 일반 데이터로 처리한다.
    • 이런 데이터를 모델링하려면 MLP나 CNN 같은 방법 중 하나를 사용할 수 있다.
  • 만일 입력이나 출력이 시퀀스라면 데이터는 다음 세 가지 중 하나로 구성된다.
    • 다대일(many-to-one): 입력 데이터가 시퀀스이다. 출력은 시퀀스가 아니라 고정 크기의 벡터이다. 예컨대 감성 분석에서 입력은 텍스트고 출력은 클래스 레이블이다.
    • 일대다(one-to-many): 입력 데이터가 시퀀스가 아니라 일반적인 형태이다. 출력은 시퀀스이다. 이런 종류의 예로는 이미지 캡셔닝이 있다. 입력이 이미지고 출력은 영어 문장이다.
    • 다대다(many-to-many): 입력과 출력 배열이 모두 시퀀스이다. 이런 종류는 입력과 출력이 동기적인지 아닌지에 따라 더 나눌 수 있다. 동기적인 다대다 모델링의 작업의 에는 각 프레임이 레이블되어 있는 비디오 분류이다. 그렇지 않은 다대다 모델의 예는 한 언어에서 다른 언어로 번역하는 작업이다.

시퀀스 모델링을 위한 RNN

RNN 구조와 데이터 흐름 이해

  • RNN 구조를 소개하겠다. 아래 그림에 비교를 위해 기본 피드포워드 신경망과 RNN을 나란히 놓았다.

  • 두 네트워크 모두 하나의 은닉층만 있다. 위 그림에서는 유닛을 표시하지 않았다.
    • 입력층(x ), 은닉층(h ), 출력층(y ) 모두 벡터고 여러 개의 유닛이 있다고 가정한다.
  • 기본 피드포워드 네트워크에서 정보는 입력에서 은닉층으로 흐른 후 은닉층에서 출력층으로 전달된다.
    • 반면 순환 네트워크에서는 은닉층이 입력층과 이전 타임 스텝(time step)의 은닉층으로부터 정보를 받는다.
    • 인접합 타임 스텝의 정보가 은닉층에 흐르기 때문에 네트워크가 이전 이벤트를 기억할 수 있다.
    • 이런 정보 흐름을 보통 루프(loop)로 표시한다. 그래프 표기법에서는 순환 에지(recurrent edge)라고도 하기 때문에 이 구조 이름이 여기서 유래되었다.
    • 아래 그림은 하나의 은닉층을 가진 순환 네트워크와 다층 순환 네트워크를 비교하여 보여준다.

  • RNN 구조와 정보 흐름을 설명하게 위해 순환 에지를 위 그림과 같이 펼쳐서 나타낼 수 있다.
  • 표준 신경망의 은닉 유닛은 입력층에 연결된 최종 입력 하나만 받는다. 반면 RNN의 은닉 유닛은 두 개의 다른 입력을 받는다.
    • 입력층으로부터 받은 입력과 같은 은닉층에서 t - 1 타임 스텝의 활성화 출력을 받는다.
    • 맨 처음 t = 0 에서는 은닉 유닛이 0 또는 작은 난수로 초기화 된다.
    • t > 0 인 타임 스텝에서는 은닉 유닛이 현재 타입 스텝의 데이터 포인트 x^{(t)} 와 이전 타입 스텝 t-1 의 은닉 유닛 값 h^{(t-1)} 을 입력으로 받는다.
  • 비슷하게 다층 RNN의 정보 흐름을 다음과 같이 요약할 수 있다.
    • layer=1: 은닉층의 출력을 h_{1}^{(t)} 로 표현한다. 데이터 포인트 x^{(t)} 와 이 은닉층의 이전 타입 스텝 출력 h_{1}^{(t-1)} 을 입력으로 받는다.
    • layer=2: 두 번째 은닉층의 h_{2}^{(t)} 는 이전 층의 현재 타임 스텝 출력 h_{1}^{(t)} 와 이 은닉층의이전 타임 스텝 출력 h_{2}^{(t-1)} 을 입력으로 받는다.

RNN의 활성화 출력 계산

  • RNN의 구조와 일반적인 정보 흐름을 이해했으므로 구체적으로 운닉층과 출력층의 실제 활성화 출력을 계산해 보겠다.
    • 간소하게 나타내기 위해 하나의 은닉층만 고려하지만 다층 RNN에도 동일한 개념이 적용된다.
  • 그림 16-4에서 유향 에지(directed edge)는 가중치 행렬과 연관된다. 이 가중치는 특정 시간 t 에 종속적이지 않고 전체 시간 축에 공유된다.
  • 단일층 RNN의 각 가중치는 다음과 같다.
    • W_{xh} : 입력 x^{(t)} 와 은닉층 h 사이의 가중치 행렬
    • W_{hh} : 순환 에지에 연관된 가중치 행렬
    • W_{hy} : 은닉층과 출력층 사이의 가중치 행렬
  • 아래 그림에 이 가중치를 나타냈다.

  • 구현에 따라 가중치 행렬 W_{xh} W_{hh} 를 합쳐 연결된 행렬 W_{h} = [W_{xh};W_{hh}] 를 사용한다. 나중에 이런 방식을 사용해 보겠다.
  • 활성화 출력의 계산은 기본적인 다층 퍼셉트론이나 다른 피드포워드 신경망과 매우 비슷하다.
    • 은닉층의 최종 입력 z_{h} (활성화 함수를 통과하기 전의 값)는 선형 조합으로 계산한다.
    • 즉, 가중치 행렬과 대응되는 벡터를 곱해서 더한 후 절편 유닛을 더한다. (z_{h}^{(t)} = W_{xh} x^{(t)} + W_{hh} h^{(t-1)} + b_{h} )
    • 그 다음 타입 스텝 t 에서 은닉층의 활성화를 계산한다.

h^{(t)} = \phi_{h} (z_{h}^{(t)}) = \phi_{h} (W_{xh} x^{(t)} + W_{hh} h^{(t-1)} + b_{h})

  • 여기서 b_{h} 은 은닉 유닛의 절편 벡터이고 \phi_{h}(\cdot) 는 은닉층의 활성화 함수이다.
  • 가중치 행렬을 W_{h} = [W_{xh};W_{hh}] 처럼 연결하면 은닉 유닛의 계산 공식은 다음과 같이 바뀐다.

h^{(t)} = \phi_{h}( [W_{xh};W_{hh}] \ \left[ \begin{array}{rr} x^{(t)} \\ h^{(t-1)} \end{array} \right] + b_{h} )

  • 현재 타임 스텝에서 은닉 유닛의 활성화 출력을 계산한 후 출력 유닛의 활성화를 다음과 같이 계산한다.

y^{(t)} = \phi_{y} (W_{hy} h^{(t)} + b_{y})

  • 이해를 돕기 위해 아래 그림에 두 공식으로 활성화 출력을 계산하는 과정을 나타냈다.

긴 시퀀스 학습의 어려움

  • 앞서 노트에서 간략히 소개한 BPTT(BackPropagation Through Time)는 새로운 도전 과제가 되었다.
  • 손실 함수의 그래디언트를 계산할 때 곱셈 항인 {\partial h^{(t)} \over \partial h^{(k)}} 때문에 소위 그래디언트 폭주(exploding gradient) 또는 그래디언트 소실(vanishing gradient) 문제가 발생한다.
    • 이 문제를 아래 그림에서 하나의 은닉 유닛이 있는 예를 들어서 설명하겠다.

  • {\partial h^{(t)} \over \partial h^{(k)}} t - k 개의 곱셈으로 이루어진다. 즉, 가중치 w t-k 번 곱해져 w_{t-k} 가 된다.
    • 결국 |w| < 1 이면 t-k 가 클 때 이 항이 매우 작아진다.
    • 반면 순환 에지의 가중치 값이 |w|>1 이면 t-k 가 클 때 w_{t-k} 가 매우 커진다.
    • t-k 값이 크다는 것은 긴 시간 의존성을 가진다는 의미이다.
  • 그래디언트 소실이나 폭주를 피하는 간단한 해결책은 |w| = 1 이 되도록 만드는 것이다. 자세한 정보는 관련 논문을 참고하고 실전에서 이 문제의 해결책은 다음과 같다.
    • T-BPTT(Truncated BackPropagation Through Time)
    • LSTM(Long Short-Term Memory)
  • T-BPTT는 주어진 타임 스텝 너머의 그래디언트를 버린다. T-BPTT가 그래디언트 폭주 문제를 해결할 수 있지만 그래디언트가 시간을 거슬러 적절하게 가중치가 업데이트 될 수 있는 타임 스텝을 제한한다.
  • 다른 방법으로 1997년 호크라이더(Hochreiter)와 슈미트후버(Schmidhuber)가 고안한 LSTM은 그래디언트 소실 문제를 극복하여 긴 시퀀스를 성공적으로 모델링할 수 있게 되었다.

LSTM 유닛

  • LSTM은 그래디언트 소실 문제를 극복하기 위해 처음 소개되었다. LSTM의 기본 구성요소는 은닉층을 의미하는 메모리 셀(memory cell)이다.
  • 이전에 언급 했듯이 그래디언트 소실과 폭주 문제를 극복하기 위해 메모리 셀에 적절한 가중치 w = 1 를 유지하는 순환 에지가 있다. 이 순환 에지의 출력을 셀 상태(cell state)라고 한다.
    • 자세한 LSTM 구조가 아래 그림에 나타나 있다.

  • 이전 타임 스텝의 셀 상태 C^{(t-1)} 은 어떤 가중치와도 직접 곱해지지 않고 변경되어 현재 타임 스텝의 셀 상태 C^{(t)} 를 얻는다.
  • 메모리 셀의 정보 흐름은 다음에 기술된 몇 개의 연산으로 제어된다.
    • 위 그림에서 \odot 는 원소별 곱셈(element-wise multiplication), \oplus 는 원소별 덧셈(element-wise addition)을 나타낸다.
    • x^{(t)} 는 타임 스텝 t 에서 입력 데이터고 h^{(t-1)} 는 타임 스텝 t-1 에서 은닉 유닛의 출력이다.
    • 네 개의 상자는 시그모이드 함수(\sigma )나 하이퍼볼릭 탄젠트(tanh) 활성화 함수와 일련의 가중치로 표시된다.
    • 이 상자는 입력에 대해 행렬-벡터 곱셈을 수행한 후 선형 조합된다.
    • 시그모이드 함수로 계산하는 유닛을 게이트(gate)라고 하며 \odot 을 통해 출력된다.
  • LSTM 셀에는 세 종류의 게이트가 있다. 삭제 게이트(forget gate), 입력 게이트(input gate), 출력 게이트(output gate)이다.
  • 삭제 게이트(f_{t} )는 메모리 셀이 무한정 성장하지 않도록 셀 상태를 다시 설정한다.
    • 사실 삭제 게이트가 통과할 정보와 억제할 정보를 결정한다.
    • f_{t} 는 다음과 같이 계산된다.

f_{t} = \sigma (W_{xf} x^{(t)} + W_{hf} h^{(t-1)} + b_{f})

  • 삭제 게이트는 원본 LSTM 셀에 포함되어 있지 않았다. 초기 모델을 향상시키기 위해 몇 년 후에 추가되었다.
  • 입력 게이트(i_{t} )와 입력 노드(g_{t} )는 셀 상태를 업데이트하는 역할을 담당하며 다음과 같이 계산한다.

i_{t} = \sigma (W_{xi} x^{(t)} + W_{hi} h^{(t-1)} + b_{i})

g_{t} = tanh(W_{xg} x^{(t)} + W_{hg} h^{(t-1)} + b_{g})

  • 타임 스텝 t 에서 셀 상태는 다음과 같이 계산한다.

C^{(t)} = (C^{(t-1)} \odot f_{t}) \oplus (i_{t} \odot g_{t})

  • 출력 게이트 (o_{t} )는 은닉 유닛의 출력 값을 업데이트 한다.

o_{t} = \sigma (W_{xo} x^{(t)} + W_{ho} h^{(t-1)} + b_{o})

  • 이를 가지고 현재 타임 스텝에서 은닉 유닛의 출력을 다음과 같이 계산한다.

h^{(t)} = o_{t} \odot tanh(C^{(t)})

  • LSTM 셀의 구조와 연산이 매우 복잡해 보일 수 있다. 다행히 텐서플로의 tf.keras API에 래퍼 함수로 이미 모두 구현되어 있어서 간단하게 LSTM 셀을 정의할 수 있다.

텐서플로의 tf.keras API로 시퀀스 모델링을 위한 다층 RNN 구현

  • RNN 이론을 소개했으므로 tf.keras API를 사용하여 RNN을 구현하는 구체적인 단계로 넘어가 보겠다. 이 장 나머지에서 두 개의 문제에 RNN을 적용하겠다.
    • 감성 분석
    • 언어 모델링

첫 번째 프로젝트: 다층 RNN으로 IMDb 영화 리뷰의 감성 분석 수행

데이터 준비

  • 8장의 전처리 단계에서 만든 정제된 데이터셋인 movie_data.csv 파일을 다시 사용하겠다.
import pyprind
import pandas as pd
from string import punctuation
import re
import numpy as np

df = pd.read_csv('movie_data.csv', encoding='utf-8')
  • 데이터프레임 df에는 ‘review’와 ‘sentiment’ 두 개의 컬럼이 있다.
    • ‘review’ 에는 영화 리뷰 텍스트가 담겨 있고 ‘sentiment’에는 0 또는 1 레이블이 들어 있다. 영화 리뷰 텍스트는 단어의 시퀀스이다.
    • RNN 모델을 만들어서 시퀀스 단어를 처리하고 마지막에 전체 시퀀스를 0 또는 1 클래스로 분류해보자.
  • 신경망에 주입할 입력 데이터를 준비하기 위해 텍스트를 정수 값으로 인코딩해야 한다. 이를 위해 전체 데이터셋에서 고유한 단어를 먼저 찾아야 한다.
    • 파이썬의 set를 사용할 수 있지만, 대규모 데이터셋에서 고유한 단어를 찾는데 집합을 사용하는 것은 효율적이지 않으므로 collection 패키지에 있는 Counter를 사용하자.
  • 아래 코드에서 Counter 클래스의 counts 객체를 정의하고 텍스트에 있는 모든 고유한 단어의 등장 횟수를 수집한다.
    • 특히 이 애플리케이션은 (BoW(Bag-ofWord) 모델과 달리) 고유한 단어의 집합만 고나심 대상이고 부수적으로 생성된 단어 카운트는 필요하지 않다.
    • 그 다음 데이터셋의 고유한 단어를 정수 숫자로 매핑한 딕셔너리를 만든다. 이 word_to_int 딕셔너리를 이용하여 전체 텍스트를 정수 리스트로 변환하겠다.
    • 고유한 단어가 카운트 순으로 정렬되어 있지만 순서는 최종 결과에 영향을 미치지 않는다.
from collections import Counter

counts = Counter()
pbar = pyprind.ProgBar(len(df['review']), title='단어의 등장 횟수를 카운트 한다')

for i, review in enumerate(df['review']):
    text = ''.join([c if c not in punctuation else ' ' + c + ' ' for c in review]).lower()
   df.loc[i, 'review'] = text
    pbar.update()
    counts.update(text.split())

word_counts = sorted(counts, key=counts.get, reverse=True)
print(word_counts[:5])

word_to_int = {word: ii for ii, word in enumerate(word_counts, 1)}

mapped_reviews = []
pbar = pyprind.ProgBar(len(df['review']), title='리뷰를 정수로 매핑합니다')

for review in df['review']:
    mapped_reviews.append([word_to_int[word] for word in review.split()])
    pbar.update()
  • 단어 시퀀스를 정수 시퀀스로 변환했지만 한 가지 풀어야 할 문제가 있다. 이 시퀀스들은 길이가 서로 다르다. RNN 구조에 맞게 입력 데이터를 생성하려면 모든 시퀀스가 동일한 길이를 가져야 한다.
    • 이를 위해 sequence_length 파라미터를 정의하고 200으로 값을 설정한다.
    • 200개의 단어보다 적은 시퀀스는 왼쪽에 0으로 패딩된다. 반대로 200개의 단어보다 긴 시퀀스는 마지막 200개의 단어만 사용하도록 잘라낸다.
    • 두 단계로 전처리 과정을 구현하면 다음과 같다.
      1. 행 길이가 시퀀스 크기 200에 해당하는 행렬을 만들고 0으로 채운다.
      2. 행렬 오른쪽부터 시퀀스의 단어 인덱스를 채운다. 시퀀스 길이가 150이면 이 행의 처음 50개 원소는 0으로 남는다.
    • 이 두 단계를 그림으로 나타내면 아래와 같다.

  • 사실 sequence_length는 하이퍼파라미터이므로 최적의 성능을 위해 튜닝해야 한다.
    • 여기서는 지면 관계상 생략했지만 sequence_length를 바꾸어보며 시도해 볼 것.
  • 코드는 아래와 같다.
sequence_length = 200
sequences = np.zeros((len(mapped_reviews), sequence_length), dtype=int)

for i, row in enumerate(mapped_reviews):
    review_arr = np.array(row)
    sequences[i, -len(row):] = review_arr[-sequence_length:]
  • 데이터셋을 전처리한 후 데이터를 훈련 세트와 테스트 세트로 나눈다. 이미 무작위로 섞여 있기 때문에 75%를 훈련 세트로, 25%를 테스트 세트로 사용한다.
    • 훈련 세트 중 일부를 모델의 fit 메서드를 호출할 때 검증 세트로 지정하겠다.
X_train = sequences[:37500, :]
y_train = df.loc[:37499, 'sentiment'].values

X_test = sequences[37500:, :]
y_test = df.loc[37500:, 'sentiment'].values

임베딩

  • 이전의 데이터 준비 단계에서 동일한 길이의 시퀀스를 생성했다. 이 시퀀스의 원소는 고유한 단어의 인덱스에 해당하는 정수 숫자이다.
    • 이런 단어 인덱스를 입력 특성을 변환하는 몇 가지 방법이 있다. 간단하게 원-핫 인코딩을 적용하여 인덱스를 0 또는 1로 이루어진 벡터로 변환할 수 있다.
    • 각 단어는 전체 데이터셋의 고유한 단어의 수에 해당하는 크기를 가진 벡터로 변환된다. 고유한 단어의 수가 2만 개라면 입력 특성 개수는 2만개가 된다.
    • 이렇게 많은 특성에서 훈련된 모델은 차원의 저주(curse of dimensionality)로 인한 영향을 받는다.
    • 또 하나를 제외하고 모든 원소가 0이므로 특성 벡터가 매우 희소해진다.
  • 좀 더 고급스러운 방법은 각 단어를 실수 값을 가진 고정된 길이의 벡터로 변환하는 것이다. 원-핫 인코딩과 달리 고정된 길이의 벡터를 사용하여 무한히 많은 실수를 표현할 수 있다.
  • 임베딩(embedding)이라고 하는 특성 학습 기법을 사용하여 데이터셋에 있는 단어를 표현하는데 중요한 특성을 자동으로 학습할 수 있다.
    • 고유한 단어의 수를 unique_words 라고 하면 고유 단어의 수보다 훨씬 작게(embedding_size << unique_words) 임베딩 벡터 크기를 선택하여 전체 어휘를 입력 특성으로 나타낸다.
  • 원-핫 인코딩에 비해 임베딩의 장점은 다음과 같다.
    1. 특성 공간의 차원이 축소되므로 차원의 저주로 인한 영향을 감소 시킨다.
    2. 신경망에서 임베딩 층이 훈련되기 때문에 중요한 특성이 추출된다.
  • 아래 그림은 임베딩이 어휘 사전의 인덱스를 어떻게 훈련 가능한 임베딩 행렬로 매핑하는지 보여준다.

  • 텐서플로에는 고유한 단어에 해당하는 정수 인덱스를 훈련 가능한 임베딩 행렬의 행으로 매핑해 주는 tf.keras.layers.Embedding 클래스가 구현되어 있다.
    • 예컨대 정수 1이 첫 번째 행으로 매핑되고 정수 2는 두 번째 행에 매핑되는 식이다.
    • <0, 5, 3, 4, 19, 2, … > 처럼 정수 시퀀스가 주어지면 시퀀스의 각 원소에 해당하는 행을 찾는다.
  • 실제로 임베딩 층을 어떻게 만드는지 알아보자. Sequential 모델을 만들고 [n_words x embedding_size] 크기의 Embedding 층을 추가하면 된다.
from tensorflow.keras import models, layers

model = models.Sequential()
model.add(layers.Embedding(n_words, 200, embeddings_regularizer='l2'))
  • Embedding 클래스의 첫 번째 매개변수는 입력 차원으로 어휘 사전의 크기가 된다. 앞서 word_to_int 크기에 1을 더해 n_words를 구했다.
    • 두 번째 매개변수는 출력 차원이다. 여기서는 200차원의 벡터로 단어를 임베딩한다.
    • 다른 층과 마찬가지로 임베딩 층도 가중치를 규제할 수 있는 매개변수를 지원한다. 이 예제에서는 L2 규제를 추가했다. 가중치 초기화는 기본적으로 균등 분포를 사용한다.
    • embeddings_initializer 매개변수에서 다른 초기화 방법을 지정할 수 있다.
  • 임베딩 층을 추가한 후에 summary 메서드로 모델 구조를 출력해 보자.
model.summary()

### 결과
# _________________________________________________________________
# Layer (type) Output Shape Param #
# =================================================================
# embedding (Embedding) (None, None, 200) 20593400
# =================================================================
# Total params: 20,593,400
# Trainable params: 20,593,400
# Non-trainable params: 0
  • 임베딩 층의 출력은 3차원 텐서이다. 첫 번째 차원은 배치 차원이고, 두 번째 차원은 타임 스텝이다. 마지막 차원이 임베딩 벡터의 차원이다.
    • 앞서 n_words 크기가 102,967이었으므로 200차원을 곱하면 전체 모델 파라미터 개수는 20,593,400이 된다.

RNN 모델 만들기

  • 이제 본격적으로 RNN 층을 추가할 차례다. 여기서는 긴 시퀀스를 학습하는데 유리한 tf.keras.layers.LSTM 층을 사용하겠다. 이 LSTM 층은 16개의 순환 유닛을 사용한다.
model.add(layers.LSTM(16))
  • LSTM 층의 첫 번째 매개변수는 유닛 개수이다. 나머지 매개변수는 모두 기본값을 사용한다.
    • 몇 가지 언급할 만한 매개변수가 있는데, activation 매개변수는 히든 상태(층의 출력)에 사용할 활성화 함수를 지정한다. 기본 값은 ‘tanh’이다.
    • recurrent_activation은 셀 상태에 사용할 활성화 함수를 지정한다. 기본값은 ‘hard_sigmoid’이다.
  • 순환층에도 드롭아웃을 추가할 수 있다. dropout 매개변수는 히든 상태를 위한 드롭아웃 비율을 지정하며, recurrent_dropout은 셀 상태를 위한 드롭아웃 비율을 지정한다.
    • 기본값은 0이다.
  • 기본적으로 순환층은 마지막 타임 스텝의 히든 상태만 출력한다. 이는 마지막 출력 값을 사용하여 모델을 평가하는데 사용하기 때문이다.
    • 만약 두 개 이상의 순환층을 쌓는다면 아래층에서 만든 모든 스텝의 출력이 위층 입력으로 전달되어야 한다. 이렇게 하려면 return_sequences 매개변수를 True로 지정해야 한다.
  • 순환층을 추가한 후에는 출력층에 연결하기 위해 펼쳐야 한다. 앞서 합성곱 신경망에서 보았던 것과 유사하다.
    • 감성 분석은 긍정 또는 부정 리뷰를 판단하는 것이므로 출력층의 유닛은 하나이고, 활성화 함수는 시그모이드 함수를 사용한다.
model.add(layers.Flatten())
model.add(layers.Dense(1, activation='sigmoid'))
  • 순환 신경망 모델이 만들어졌다. 완전 연결 신경망이나 합성곱 신경망 보다 어렵지 않다. 전체 모델 구조를 살펴보자.
model.summary()

### 결과
# _________________________________________________________________
# Layer (type) Output Shape Param #
# =================================================================
# embedding (Embedding) (None, None, 200) 20593400
# _________________________________________________________________
# lstm (LSTM) (None, 16) 13888
# _________________________________________________________________
# flatten (Flatten) (None, 16) 0
# _________________________________________________________________
# dense (Dense) (None, 1) 17
# =================================================================
# Total params: 20,607,305
# Trainable params: 20,607,305
# Non-trainable params: 0
  • LSTM 층의 출력 크기는 (None, 16)이다. 첫 번째 차원은 배치 차원이고, 두 번째 차원은 셀의 출력(유닛 개수) 차원이다. LSTM 층이 가지는 모델 파라미터 개수는 13,888개 이다.
  • 좀 더 자세히 분석해 보자. 먼저 삭제 게이트(f_{t} )에 필요한 모델 파라미터를 계산해 보자.
    • 임베딩된 입력 벡터와 곱해지는 W_{xf} 는 (16, 200) 차원이고 이전 셀의 히든 상태와 곱해지는 W_{hf} 는 (16, 16) 차원이다. 마지막으로 절편은 유닛마다 하나씩 있으므로 b_{f} 는 (16,) 차원이다. 이를 모두 더하면 16 x 200 + 16 x 16 + 16 = 2,472 개이다.
    • LSTM 층에는 삭제 게이트와 같은 계산이 세 개 더 있다. i_{t}, g_{t}, o_{t} 이다. 이들 모두 동일한 차원의 가중치 두 개와 절편을 가진다. 따라서 LSTM 층에 있는 전체 모델 파라미터는 3,472 x 4 = 13,888개가 된다.
    • 마지막 Dense 층은 16개의 입력을 처리하기 위한 가중치와 절편을 합쳐서 17개의 모델 파라미터가 있다.

감성 분석 RNN 모델 훈련

  • 모델 구성을 완료 했으므로 Adam 옵티마이저를 사용하여 모델을 컴파일해 보자. 감성 분석 문제는 이진 분류 문제이므로 손실 함수는 binary_crossentropy로 지정한다.
model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['acc'])
  • 합성곱 신경망에서 했던 ㄱ서처럼 가장 좋은 검증 점수의 모델 파라미터를 체크포인트로 저장하고 텐서보드를 취한 출력을 지정하겠다.
import time
from tensorflow.keras.callbacks import ModelCheckpoint, TensorBoard

callback_list = [ModelCheckpoint(filepath='sentiment_rnn_checkpoint.h5', monitor='val_loss', save_best_only=True), TensorBoard(log_dir="sentiment_rnn_logs/{}".format(time.asctime()))]

# 역시나 log_dir 폴더가 안 만들어져서 그냥 TensorBoard()만 사용
# callback_list = [ModelCheckpoint(filepath='sentiment_rnn_checkpoint.h5', monitor='val_loss', save_best_only=True), TensorBoard()]
  • 이제 모델을 훈련할 단계이다. 배치 크기는 64로 지정하고 열 번 에포크 동안 훈련하겠다.
    • validation_split을 0.3으로 지정하여 전체 훈련 세트의 30%를 검증 세트로 사용한다.
history = model.fit(X_train, y_train, batch_size=64, epochs=10, validation_split=0.3, callbacks=callback_list)
  • fit 메서드에서 반환된 history 객체에서 손실과 정확도를 추출하여 그래프로 그려보자. 먼저 손실 점수에 대한 그래프이다.
import matplotlib.pyplot as plt

epochs = np.arange(1, 11)

plt.plot(epochs, history.history['loss'])
plt.plot(epochs, history.history['val_loss'])
plt.xlabel('epochs')
plt.ylabel('loss')
plt.show()

  • 정확도 그래프를 그려보자.
epochs = np.arange(1, 11)

plt.plot(epochs, history.history['acc'])
plt.plot(epochs, history.history['val_acc'])
plt.xlabel('epochs')
plt.ylabel('loss')
plt.show()

  • 출력 결과를 보면 2번째 에포크만에 손실이 크게 감소하고 정확도가 상승한 것을 볼 수 있다.
    • 그 이후에는 훈련 정확도와 간격을 두며 검증 정확도가 조금씩 상승하고 있다. 이런 효과는 임베딩 층에 L2 규제를 추가했기 때문이다.
    • 임베딩 층에 규제가 없다면 훈련 세트에 금방 과대적합될 것이다.

감성 분석 RNN 모델 평가

  • 훈련 과정에서 만들어진 최상의 체크포인트 파일을 복원하여 테스트 세트에서 성능을 평가해보자.
    • 체크 포인트를 복원 하려면 모델의 load_weights 메서드를 사용하면 된다.
model.load_weights('sentiment_rnn_checkpoint.h5')
model.evaluate(X_test, y_test)

### 결과
# ...======] - 27s 2ms/sample - loss: 0.4732 - acc: 0.8777
  • evaluate 메서드는 기본적으로 손실 점수를 반환한다. 만약 compile 메서드의 metrics 매개변수에 측정 지표를 추가했다면 반환되는 값이 늘어난다.
    • 앞서 반환된 결과의 첫 번째 원소는 손실 점수고, 두 번째는 정확도 이다.
    • LSTM 층 하나로 테스트 세트에서 87% 정도의 정확도를 달성했는데, 8장에서 얻은 테스트 정확도와 비교할 만하다.
  • 샘플의 감성 분석 결과를 출력하려면 predict 메서드를 사용한다.
    • 이전 장에서 보았듯이 predict 메서드는 확률 값을 반환한다. 감성 분석 예제는 이진 분류 문제이므로 양성 클래스, 즉 긍정 리뷰일 확률을 반환한다.
    • Sequential 클래스는 predict 메서드와 동일하게 확률을 반환하는 predict_proba 메서드를 제공한다.
    • 의도를 분명하게 하기 위해 predict_proba 메서드로 테스트 샘플 열 개의 확률을 출력해 보자.
print(model.predict_proba(X_test[:10]))

### 결과
# [[0.00631011]
# [0.00777742]
# [0.00151676]
# [0.95133054]
# [0.99530613]
# [0.9786582 ]
# [0.00557807]
# [0.8497387 ]
# [0.00201363]
# [0.5879719 ]]
  • 다중 분류에서는 가장 큰 확률의 레이블이 예측 클래스가 되고 이진 분류 문제에서는 0.5보다 크면 양성 클래스가 된다.
    • 간단하게 0.5보다 큰 값을 구분할 수 있지만 Sequential 클래스는 친절하게 이를 위한 predict_classes 메서드도 제공한다.
print(model.predict_classes(X_test[:10]))

### 결과
# [[0]
# [0]
# [0]
# [1]
# [1]
# [1]
# [0]
# [1]
# [0]
# [1]]
  • 최적화를 위해 LSTM 층의 유닛 개수, 타임 스텝의 길이, 임베딩 크기 같은 모델의 하이퍼파라미터를 튜닝하면 더 높은 일반화 성능을 얻을 수 있다.
    • 6장에서 설명한 것처럼 테스트 데이터를 사용하여 편향되지 않은 성능을 얻으려면 평가를 위해 테스트 세트를 반복적으로 사용하면 안된다는 점에 주의하라.

두 번째 프로젝트: 텐서플로로 글자 단위 언어 모델 구현

  • 언어 모델링(language modeling)은 영어 문장 생성처럼 기계가 사람의 언어와 관련된 작업을 수행하도록 만드는 흥미로운 애플리케이션이다.
    • 이 분야에서 관심을 끄는 결과물 중 하나는 서스키버(Sutskever), 마틴(Martens), 힌튼(Hinton)의 작업이다.
  • 앞으로 만들 모델의 입력은 텍스트 문장이다. 목표는 입력 문서와 비슷한 새로운 텍스트를 생성하는 모델을 개발하는 것이다.
    • 입력 데이터는 책이나 특정 프로그래밍 언어로 쓰여진 컴퓨터 프로그램일 수 있다.
  • 글자 단위 언어 모델링에서 입력은 글자의 시퀀스로 나뉘어 한 번에 글자 하나씩 네트워크에 주입된다.
    • 이 네트워크는 지금까지 본 글자와 함께 새로운 글자를 처리하여 다음 글자를 예측한다.
    • 아래 그림은 글자 단위 언어 모델링의 예이다.

  • 데이터 전처리, RNN 모델 구성, 다음 글자를 예측하고 새로운 텍스트를 생성하는 세 개의 단계로 나누어 구현하겠다.
  • 이 장의 서두에서 그래디언트 폭주 문제를 언급했는데, 이 애플리케이션에서 그래디언트 폭주 문제를 피하기 위해 그래디언트 클리핑 기법을 사용해 보겠다.

데이터 전처리

  • 글자 수준의 언어 모델링을 위한 데이터를 준비하자.
    • 수천 권의 무료 전자책을 제공하는 구텐베르크 프로젝트 웹사이트에서 입력 데이터를 구하겠다. 이 예에서는 셰익스피어의 햄릭 텍스트를 사용하겠다. (http://www.gutenberg.org/cache/epub/2265/pg2265.txt)
  • 데이터가 준비되면 파이썬에서 일반 텍스트로 읽는다.
    • 다음 코드에서 파이썬 매개변수 chars는 이 텍스트에 있는 고유한 글자 집합이다.
    • 그 다음 각 글자와 정수를 매핑한 딕셔너리 char2int와 거꾸로 정수와 글자를 매핑한 int2char 딕셔너리를 만든다.
    • char2int 딕셔너리를 사용하여 텍스트를 넘파이 정수 배열로 변환한다.
    • 아래 그림은 변환 예시이다.

import numpy as np

with open('pg2265.txt', 'r', encoding='utf-8') as f:
    text = f.read()

text = text[15858:]
chars = set(text)
char2int = {ch:i for i, ch in enumerate(chars)}
int2char = dict(enumerate(chars))
text_ints = np.array([char2int[ch] for ch in text], dtype=np.int32)

print(len(text))
print(len(chars))

### 결과
# 163237
# 68
  • 이 텍스트를 char2int 딕셔너리를 사용하여 모두 정수로 바꾸어 text_ints 배열에 저장했다.
  • 데이터 전처리에서 가장 중요한 단계는 이 데이터를 시퀀스의 배치로 바꾸는 작업이다. 지금까지 본 글자 시퀀스를 기반으로 새로운 글자를 예측하는 것이 목적이다.
    • 따라서 신경망의 입력(x )과 출력(y )을 한 글자씩 이동한다.
    • 텍스트 데이터셋에서 데이터 배열 x y 를 생성하는 것부터 시작해서 아래 그림에 이 전처리 단계를 나타냈다.

  • 그림에서 볼 수 있듯이 훈련 배열 x y 는 동일한 크기 또는 차원을 가진다. 행 개수는 배치 크기와 같고, 열 개수는 배치 횟수 x 스텝 횟수이다.
  • 텍스트 데이터의 글자를 표현한 정수 입력 배열 data가 주어지면 다음 함수는 위의 그림과 동일한 구조의 x y 를 만든다.
def reshape_data(sequence, batch_size, num_steps):
    mini_batch_length = batch_size * num_steps
    num_batches = int(len(sequence) / mini_batch_length)

   if num_batches * mini_batch_length + 1 > len(sequence):
       num_batches = num_batches - 1   

   x = sequence[0: num_batches * mini_batch_length]
    y = sequence[1: num_batches * mini_batch_length + 1]

   x_batch_splits = np.split(x, batch_size)
    y_batch_splits = np.split(y, batch_size)

    x = np.stack(x_batch_splits)
    y = np.stack(y_batch_splits)

    return x, y
  • 시퀀스 길이를 10으로 가정하고 reshape_data 함수를 사용하여 배치 크기 64에 맞게 데이터를 바꾸어 보자.
train_x, train_y = reshape_data(text_ints, 64, 10)

print(train_x.shape)
print(train_x[0, :10])
print(train_y[0, :10])
print(''.join(int2char[i] for i in train_x[0, :10]))
print(''.join(int2char[i] for i in train_y[0, :10]))

### 결과
# (64, 2550)
# [49 48 2 63 48 14 2 38 49 48]
# [48 2 63 48 14 2 38 49 48 40]
# e of more
# of more t
  • text_ints를 64개의 행을 가진 2차원 배열 train_x와 train_y로 바꾸었다. train_x 크기를 출력해 보면 배치 크기의 행이 만들어진 것을 확인할 수 있다.
    • train_x와 train_y를 정수 크기대로 출력하고 int2char 딕셔너리를 사용하여 문자로도 출력했다.
    • 출력 결과를 보면 train_y가 한 글자씩 밀려 있다는 것을 확인할 수 있다.
  • 다음 단계에서 배열 x y 를 나누어 열 길이가 스텝 횟수와 동일한 미니 배치를 만든다. 데이터 배열 x 를 나누는 과정이 아래 그림에 나와있다.

  • 다음 코드에서 위 그림에 나온 데이터 배열 x y 를 나누어 배치를 출력하는 create_batch_generator를 정의한다.
    • 차후에 이  제너레이터를 사용하여 네트워크를 훈련하는 동안 미니 배치를 반복하겠다.
def create_batch_generator(data_x, data_y, num_steps):
    batch_size, tot_batch_length = data_x.shape[0:2]
    num_batches = int(tot_batch_length/num_steps)

    for b in range(num_batches):
        yield (data_x[:, b * num_steps:(b+1) * num_steps], data_y[:, b * num_steps: (b+1) * num_steps])
  • 이 코드에서 정의한 제너레이터는 메모리 부족을 해결할 수 있는 좋은 기법이다.
    • 신경망을 훈련하는 동안 모든 데이터를 미리 나누어 메모리에 저장하지 않고 데이터셋을 미니 배치로 나누는 방식이 바람직하다.
  • train_x와 train_y 배열에서 길이 100까지만 사용하여 배치 데이터를 테스트로 만들어보겠다.
    • 시퀀스 길이는 15로 설정하고, 길이가 100이므로 제너레이터 함수는 길이 15인 시퀀스의 배치를 여섯 번 반환한다.
bgen = create_batch_generator(train_x[:, :100], train_y[:, :100], 15)

for x, y in bgen:
   print(x.shape, y.shape, end='  ')
    print(''.join(int2char[i] for i in x[0, :]).replace('\n', '*'), '  ', ''.join(int2char[i] for i in y[0, :]).replace('\n', '*'))

### 결과
# (64, 15) (64, 15) e of more than of more than 3
# (64, 15) (64, 15) 30 different*Fi 0 different*Fir
# (64, 15) (64, 15) rst Folio editi st Folio editio
# (64, 15) (64, 15) ons' best pages ns' best pages.
# (64, 15) (64, 15) .**If you find **If you find a
# (64, 15) (64, 15) any scanning er ny scanning err
  • 64개 배치 중 첫 번째 배치만 문자로 바꾸어 출력했다. 훈련 데이터와 타깃 데이터가 올바르게 추출되었다.
  • 실제 모델에 사용할 데이터를 만들기 위한 준비를 거의 마쳤다. 먼저 reshape 메서드를 사용하여 text_ints 배열을 64개의 배치 행을 가진 형태로 바꾼다.
batch_size = 64
num_steps = 100
train_x, train_y = reshape_data(text_ints, batch_size, num_steps)

print(train_x.shape, train_y.shape)

### 결과
# (64, 2500) (64, 2500)
  • 배치 크기를 64, 타임 스텝 길이를 100으로 설정했으므로 text_ints에서 자투리 부분은 제외하고 (64, 2500) 크기의 배열이 되었다.
  • 데이터 전처리의 마지막 단계는 이 데이터를 원-핫 인코딩으로 바꾸는 작업이다.
    • 이전 감성 분석 예제에서는 임베딩 층을 사용하여 단어를 길이가 200인 벡터로 인코딩했었다. 이때 타깃 데이터는 긍정 또는 부정 리뷰를 나타내는 1차원 배열이었다. 일련의 시퀀스를 처리한 후 손실 함수로부터 그래디언트를 계산했다.
    • 글자 단위 RNN 모델에서는 조금 다른 방식을 사용하는데, 모델에서 처리하는 글자마다 그래디언트를 모두 계산하여 사용한다. 이렇게 하려면 타깃 데이터도 전체 타임 스텝에 걸쳐 원-핫 인코딩 되어야 한다.
    • 텐서플로에서 제공하는 to_categorical 함수를 사용하여 원-핫 인코딩을 간단하게 만들어보자.
from tensorflow.keras.utils import to_categorical

train_encoded_x = to_categorical(train_x)
train_encoded_y = to_categorical(train_y)

print(train_encoded_x.shape, train_encoded_y.shape)

### 결과
# (64, 2500, 68) (64, 2500, 68)
  • to_categorical 함수는 입력된 데이터에서 가장 큰 값에 맞추어 자동으로 원-핫 인코딩된 벡터로 변환시킨다.
    • 정수 값이 0부터 시작한다고 가정하므로 원-핫 인코딩 벡터의 길이는 최댓값에 1을 더해야 한다.
    • 만약 train_x와 train_y에 있는 최댓값이 다르면 원-핫 인코딩 크기가 달라진다. 여기서는 train_y가 train_x에서 한 글자만 이동했기 때문에 최댓값이 같지만 문제에 따라 다를 수 있으므로 주의하라

글자 단위 RNN 모델 만들기

  • Sequential 클래스를 사용하여 글자 단위 RNN 모델을 만들어보겠다. 먼저 Sequential 클래스 객체를 생성한다.
from tensorflow.keras import models, layers

char_model = models.Sequential()
  • 훈련 데이터를 원-핫 인코딩 했으므로 임베딩 층 대신 LSTM 층을 바로 추가하겠다. 이때 두 가지를 고려해야 한다.
  • 첫째, 이 모델은 훈련할 때 길이가 100인 시퀀스를 주입한다. 즉, 타임 스텝 길이가 100이다. 하지만 새로운 글자를 생성할 때는 이전 글자를 주입하여 한 글자씩 생성한다. 다시 말해 샘플링 시에는 배치 크기가 1이 된다. 따라서 훈련과 샘플링 시에 배치 크기와 타임 스텝 크기가 다음과 같이 정의된다.

sampling mode = \begin{cases} batch size = 1 \\ num steps = 1 \end{cases}

training mode = \begin{cases} batch size = 64 \\ num steps = 100 \end{cases}

  • 훈련과 샘플링 모드에서 사용하는 시퀀스 길이가 다르다. 흔히 이런 RNN 네트워크의 구조를 ‘시간에 따라 동적으로 펼친다’라고도 한다.
    • 텐서플로의 케라스 API를 사용하면 가변 길이 시퀀스를 다루는 작업도 간단하게 처리할 수 있다.
  • 이전 장의 합성곱 모델에서 보았듯이 모델에 추가하는 첫 번째 층에는 input_shape 매개변수로 배치 차원을 제외한 입력 크기를 지정해야 한다.
    • LSTM 층에서 가변 길이 시퀀스를 처리하려면 타임 스텝 길이에 해당하는 input_shape의 첫 번째 차원을 None으로 지정하면 된다.
    • 두 번째 차원은 원-핫 인코딩 벡터의 크기가 된다.
  • 둘째, 모든 타임 스텝에 대해 그래디언트를 계산하여 모델을 업데이트할 것이다. 따라서 LSTM 층이 시퀀스의 마지막 타임 스텝의 출력만 반환하지 않고 전체 시퀀스에 대해 출력을 만들어야 한다.
    • 이렇게 하려면 앞서 언급한 대로 LSTM 층의 return_sequences 매개변수를 True로 지정해야 한다.
  • 이런 점을 고려하여 다음과 같이 128개의 순환 유닛을 가진 LSTM 층을 모델에 추가한다.
num_classes = len(chars)
char_model.add(layers.LSTM(128, input_shape=(None, num_classes), return_sequences=True))
  • 이 모델에 입력할 데이터는 num_classes 크기로 원-핫 인코딩 되었다는 것을 기억하라. num_classes는 텍스트에 있는 모든 글자 수이다.
  • 그 다음은 각 글자에 대한 확률을 출력하는 완전 연결 층을 추가한다. 이 출력층의 유닛 개수는 num_classes가 된다. 다중 출력이므로 활성화 함수는 소프트맥스 함수를 사용한다.
  • 지금까지는 Dense 층을 추가하기 전에 Flatten 층을 추가했다. 이 층은 배치 차원을 제외하고 입력 텐서의 나머지 차원을 일렬로 펼친다. Dense 층은 이렇게 전형적으로 2차원 텐서를 다룬다.
    • 하지만 이 예제에서는 모든 타임 스텝에 대한 손실을 계산해야 하기 때문에 LSTM 층에서 출력되는 3차원 텐서를 그대로 다루어야 한다.
    • LSTM 층에서 출력되는 텐서 크기는 (배치 개수, 타임 스텝 개수, 순환 유닛 개수)이다. Dense 층을 통과할 때 이 텐서츼 엇 번째와 두 번째 차원이 유지되어야 한다.
    • 이 작업을 처리하기 위해 Flatten 층을 추가하지 않고 LSTM 층의 출력을 타임 스텝 순으로 Dense 층에 주입하고 결과를 받아 다시 타임 스텝 순서대로 쌓아야 한다.
    • 이런 작업을 위한 클래스도 텐서플로에 이미 준비되어 있다. tf.keras.layers.TimeDistribute 클래스를 사용하면 Dense 층을 감싸서 타임 스텝을 가진 입력을 다룰 수 있다.
char_model.add(layers.TimeDistributed(layers.Dense(num_classes, activation='softmax')))
  • 전체 모델 구성이 끝났다. tf.keras API를 사용하면 간단하게 RNN 모델을 만들 수 있다.
    • summary 메서드로 구성된 네트워크를 출력해 보자.
char_model.summary()

### 결과
# _________________________________________________________________
# Layer (type) Output Shape Param #
# =================================================================
# lstm (LSTM) (None, None, 128) 100864
# _________________________________________________________________
# time_distributed (TimeDistri (None, None, 68) 8772
# =================================================================
# Total params: 109,636
# Trainable params: 109,636
# Non-trainable params: 0
  • 가변 길이 시퀀스를 다루기 위해 LSTM 층과 TimeDistributed 층의 출력에서 두 번째 차원이 None으로 된 것을 볼 수 있다. 또 최종 출력에 타임 스텝 차원이 포함되었다.
  • 모델 구성을 마치면서 각 츠으이 모델 파라미터의 크기를 계산해 보자.
    • 순환 유닛이 128개이고 원-핫 인코딩의 크기가 65이므로 W_{xf} 는 (128, 65) 차원이다. 셀의 히든 상태와 곱해지는 W_{hf} 는 (128, 128)이다. 여기에 절편을 더하면 삭제 게이트에 필요한 모델 파라미터 개수는 128 x 65 + 128 x 128 + 128 = 24,832 개가 된다.
    • LSTM 층에는 이런 가중치가 네 벌 더 있으므로 전체 모델 파라미터 개수는 99,328개가 된다.
  • TimeDistributed 층은 모델 파라미터를 가지고 있지 않다. summary 메서드에서 출력한 값은 Dense 층의 모델 파라미터 개수이다.
    • Dense 층의 입력 차원은 128이고 65개의 유닛이 있으므로 절편을 고려한 전체 모델 파라미터 개수는 65 x 128 + 65 = 8,386개이다.

글자 단위 RNN 모델 훈련

  • 이전 예제에서는 옵티마이저의 기본값을 사용했다. 이 예제에서는 그래디언트 폭주를 피하기 위한 대표적인 방법인 그래디언트 클리핑을 적용해 보겠다.
    • 그래디언트 클리핑을 하려면 옵티마이저 클래스의 객체를 직접 만들어 모델의 compile 메서드에 전달해야 한다.
    • 앞선 예제와 같이 Adam 옵티마이저를 사용한다.
from tensorflow.keras.optimizers import Adam

adam = Adam(clipnorm=5.0)
  • tf.keras.optimizers에 있는 옵티마이저들은 그래디언트 클리핑을 위한 두 개의 매개변수를 제공한다. 하나는 L2 노름 임계 값을 지정하는 clipnorm이고 다른 하나는 절댓값으로 임계 값을 지정하는 clipvalue이다.
    • clipnorm 매개변수가 설정되면 그래디언트의 L2 노름이 clipnorm 보다 클 경우 다음과 같이 클리핑 된 그래디언트를 계산한다.
      • 클리핑된 그래디언트 = 그래디언트 * clipnorm / 그래디언트의 L2 노름
    • clipvalue 매개변수가 설정되면 -clipvalue 보다 작은 그래디언트는 -clipvalue가 되고 clipvalue 보다 큰 그래디언트는 clipvalue로 만든다.
  • 이 두 클리핑 방식을 동시에 사용할 수도 있다. 여기서는 clipnorm 매개변수만 사용했다.
    • 65개의 글자에 대한 확률을 출력하는 다중 클래스 모델이므로 손실 함수는 categorical_crossentropy를 사용한다.
    • 그 다음 옵티마이저 객체와 함께 char_model을 컴파일 한다.
char_model.compile(loss='categorical_crossentropy', optimizer=adam)
  • 훈련된 모델을 저장하여 나중에 학습을 이어 가거나 텍스트를 생성할 수 있도록 체크포인트 콜백을 준비한다.
from tensorflow.keras.callbacks import ModelCheckpoint

callback_list = [ModelCheckpoint(filepath='char_rnn_chckpoint.h5')]
  • 이제 500번의 에포크 동안 모델을 훈련하겠다. Sequential 모델은 입력과 타깃 배치를 반환하는 제너레이터와 함께 쓸 수 있는 fit_generator 메서드를 제공한다.
    • 앞서 만든 create_batch_generator 함수로부터 제너레이터 객체를 만들어 fit_generator 메서드에 전달하겠다.
    • file_generator 메서드는 파이썬 제너레이터에서 배치를 끝없이 반환할 것으로 기대한다. 데이터가 끝없이 생성되므로 하나의 에포크를 정의하기 위해 제너레이터로부터 몇 번이나 배치를 뽑을 것인지 알려주어야 한다.
    • fit_generator 메서드의 steps_per_epoch 매개변수에서 이를 설정한다.
  • 이 예제에서는 시퀀스 길이가 100이므로 전부 25번의 배치가 생성된다.
    • 사실 create_batch_generator 함수는 배치를 순환하지 않기 때문에 25번째 배치 이후에는 더는 추출하지 못하고 에러가 발생된다.
    • 이를 해결하기 위해 for 반복문에서 fit_generator 메서드를 호출할 때 epochs를 1로 설정한다.
    • 전체 훈련 횟수는 500번이고 훈련할 때마다 제너레이터를 다시 초기화 해야 한다.
for i in range(500):
    bgen = create_batch_generator(train_encoded_x, train_encoded_y, num_steps)
   char_model.fit_generator(bgen, steps_per_epoch=25, epochs=1, callbacks=callback_list, verbose=0)

### 결과
# 윈도우 설정 문제인지, 책 코드가 있는 git의 소스를 그대로 써도 에러가 나서 이 예제는 이후 결과 없이 종료
  • 반복 횟수가 많기 때문에 verbose 매개변수를 0으로 설정하여 훈련 과정을 출력하지 않았다.

글자 단위 RNN 모델로 텍스트 생성

  • 텍스트를 만들기 위해 앞서 설명한 것처럼 배치 크기 1, 타임 스텝 길이 1을 만들어 모델에 주입한다. 그 다음 예측된 문자를 다음번 예측을 하기 위해 다시 모델에 주입하는 과정을 반복한다.
  • 먼저 모델에서 출력된 65개의 확률 값에서 하나를 랜덤하게 선택할 get_top_char 함수를 정의하자. 이 함수는 전달된 확률을 정렬하고 numpy.random.choice 함수로 상위 다섯 개의 확률 중 하나를 랜덤하게 선택한다.
np.random.seed(42)

def get_top_char(probas, char_size, top_n=5):
    p = np.squeeze(probas)
   p[np.argsort(p)[:-top_n]] = 0.0
    p = p / np.sum(p)
    ch_id = np.random.choice(char_size, 1, p=p)[0]

    return ch_id
  • “The ” 란 초기 문자열을 사용하여 이어지는 텍스트를 생성하겠다. 텍스트를 생성하는 방법은 다음과 같다.
    • 먼저 모델에 문자열 “The “를 한 글자씩 주입하고 마지막 글자에서 다음 글자를 예측한다.
    • 그 다음 이글자를 사용하여 계속 다음 글자를 예측하는 식이다.
  • 모델에 주입할 데이터를 만드는 과정은 앞서 훈련 데이터에서 했던 것과 유사하다. 한 가지 주의할 점은 한 글자씩 인코딩 하기 때문에 to_categorical 함수를 호출할 때 num_classes 매개변수로 원-핫 인코딩될 벡터 크기를 지정해야 한다.
    • 배치 차원을 만들기 위해 넘파이 expand_dims 함수로 첫 번째 차원을 추가했다.
    • 만들어진 onehot 배열의 차원은 (1, 1, 65)이다.
seed_text = "The "

for ch in seed_text:
   num = [char2int[ch]]
   onehot = to_categorical(num, num_classes=65)
    onehot = np.expand_dims(onehot, axis=0)
    probas = char_model.predict(onehot)

num = get_top_char(probas, len(chars))
seed_text += int2char[num]
  • 초기 문자열 “The “를 모델에 차례대로 주입한 후 마지막에 얻은 probas 출력을 사용하여 다음 글자를 예측한다.
    • 이 값은 정수이기 때문에 int2char를 사용하여 문자로 바꾼 후 seed_text 문자열 끝에 추가한다.
  • 이제 예측한 문자열을 비슷한 과정으로 인코딩하여 다시 모델에 주입한다.
    • 반환된 클래스 확률 값을 사용하여 다시 다음 글자를 선택한다.
    • 이런 과정을 for 반복문을 사용하여 500번 되풀이 하여 긴 텍스트를 만들어 보자.
for i in range(500):
   onehot = to_categorical([num], num_classe=65)
    onehot = np.expand_dims(onehot, axis=0)
    probas = char_model.predict(onehot)
   num = get_top_char(probas, len(chars))
    seed_text += int2char[num]

print(seed_text)
  • 결과에서 볼 수 있듯이 일부 영어 단어는 거의 그대로 유지되었다. 이 예제는 오래된 영어 텍스트를 사용했으므로 원본 텍스트에는 낯선 단어가 일부 포함되어 있다.
  • 더 나은 결과를 얻으려면 에포크 수를 늘려서 모델을 훈련하거나 훨씬 더 큰 문서를 사용해도 좋다. np.random.choice 대신 확률 값의 크기에 따라 글자 선택 가능성을 높일 수도 있다.

머신 러닝 교과서/ 심층 합성곱 신경망으로 이미지 분류

합성곱 신경망의 구성 요소

  • 합성곱 신경망 또는 CNN은 뇌의 시각 피질이 물체를 인식할 때 동작하는 방식에서 영감을 얻은 모델이다.
    • CNN 개발은 1990년대로 거슬러 올라간다. 이 시기에 얀 르쿤(Yann LeCun)과 그의 동료들은 손글씨 숫자를 분류하는 새로운 신경망 구조를 발표했다.
    • 이미지 분류 작업에서 CNN이 탁월한 성능을 내기 때문에 크게 주목을 받았다. 이로 인해 머신 러닝과 컴퓨터 비전 애플리케이션에서 엄청난 발전을 이루었다.

CNN과 특성 계층 학습

  • 관련이 높은 핵심 특징을 올바르게 추출하는 것은 모든 머신 러닝 알고리즘의 성능에서 아주 중요한 요소이다.
    • 전통적인 머신 러닝 모델은 도메인 전문가가 만든 특성에 의존하거나 컴퓨터를 사용한 특성 추출 기법에 바탕을 두고 있다.
    • 신경망은 원본 데이터에서 작업에 가장 유용한 특성을 자동으로 학습한다. 이런 이유 때문에 신경망을 특성 추출 엔진으로 생각하기도 한다. 예컨대 입력에 가까운 층은 저수준 특성을 추출한다.
  • 다층 신경망과 특히 심층 합성곱 신경망은 각 층별로 저수준 특성을 연결하여 고수준 특성을 만듦으로써 소위 특성 계층을 구성한다.
    • 예컨대 이미지를 다룬다면 에지(edge)나 동그라미 같은 저수준 특성이 앞쪽 층에서 추출된다.
    • 이런 특성들이 연결되어 건물, 자동차, 강아지 같은 고수준 특성을 형성한다.
  • 아래 그림에서 보듯이 CNN은 입력 이미지에서 특성 맵(feature map)을 만든다. 이 맵의 각 원소는 입력 이미지의 국부적인 픽셀 패치에서 유도된다.

  • 이런 국부적인 픽셀 패치를 국부 수용장(local receptive field)라고 한다. CNN은 일반적으로 이미지 관련 작업을 매우 잘 수행한다. 이는 다음 두 개의 중요한 아이디어 때문이다.
    • 희소 연결: 특성 맵에 있는 하나의 우너소는 작은 픽셀 패치 하나에만 연결된다 (퍼셉트론 처럼 모든 입력 이미지에 있는 연결되는 것과 매우 다르다)
    • 파라미터 공유: 동일한 가중치가 입력 이미지의 모든 패치에 사용된다.
  • 이 두 아이디어 결과로 네트워크의 가중치(파라미터) 개수가 극적으로 감소하고 중요 특징을 잡아내는 능력이 향상된다. 당연히 가까이 있는 픽셀들이 멀리 있는 픽셀보다 연관성이 높다.
  • 일반적으로 CNN은 여러 개의 합성곱(conv) 층과 풀링(Pooling)이라고도 하는 서브샘플링(subsampling) 층으로 이루어져 있다. 마지막에는 하나 이상의 완전 연결(FC) 층이 따라온다.
    • 완전 연결 층은 모든 입력 유닛 i 가 모든 출력 유닛 j 에 가중치 w_{ij} 로 연결되어 있는 다층 퍼셉트론이다.
  • 풀링 층으로 알려진 서브샘플링 층은 학습되는 파라미터가 없다. 즉, 풀링 층에는 가중치나 절편 유닛이 없다. 합성곱이나 완전 연결 층은 가중치와 절편을 가진다.

이산 합성곱 수행

  • 이산 합성공(discrete convolution)(또는 간단히 합성곱)이 CNN의 기본 연산이다. 이 연산의 작동 원리를 아는 것이 아주 중요하다.
    • 여기서는 합성곱의 수학적 정의를 살펴보고 1차원 벡터나 2차원 행렬에서 합성곱을 계산하는 간단한 알고리즘을 설명하겠다.
    • 여기서는 합성곱 연산의 작동 원리를 이해하는 것이 목적이다. 텐서플로 패키지의 실제 합성곱 연산은 훨씬 효율적으로 구현되어 있다.

1차원 이산 합성곱 연산 수행

  • 앞으로 사용할 기본적인 정의와 기호를 설명하는 것부터 시작하겠다.
    • 두 개의 1차원 벡터 x w 에 대한 이산 합성곱은 y = x * w 로 나타낸다.
    • x 는 입력이고 (이따금 신호라고 부름) w 는 필터(filter) 또는 커널(kernel)이라 부른다.
    • 이산 합성곱의 수학적 정의는 아래와 같다.

y = x * w \to y[i] = \sum_{k=-\infty}^{+\infty} x[i-k] w[k]

  • 여기서 대괄호는 벡터 원소의 인덱스를 나타내는데 사용한다. 인덱스 i 는 출력 벡터 y 의 각 원소에 대응한다.
  • 위 공식에서 특이한 점이라면 -\infty 에서 +\infty 까지의 인덱스와 x 의 음수 인덱싱이다.
  • 인덱스 -\infty 에서 \infty 까지의 합은 특히 이상하게 보인다. 머신 러닝 애플리케이션은 항상 유한한 특성 벡터를 다루기 때문이다.
    • 예컨대 x 0, 1, 2, ... , 8, 9 인덱스로 열 개의 특성을 가지고 있다면 -\infty:-1 10:+\infty 인덱스는 x 의 범위 밖이다.
    • 이전 공식에 있는 덧셈을 올바르게 계산하려면 x w 가 0으로 채워져 있다고 가정해야 한다. 또 출력 벡터 y 도 0으로 채워진 무한 크기가 된다.
    • 이는 실제 상황에서는 유용하지 않기 때문에 유한한 개수의 0으로 x 가 패딩된다.
    • 이 과정을 제로 패딩(zero padding) 또는 패딩(padding)이라고 한다. 각 방향으로 추가된 패딩 수는 p 로 나타난다.
    • 1차원 벡터 x 의 패딩 예가 아래 그림에 나타나 있다.

  • 원본 입력 x 와 필터 w 가 각각 n m 개의 원소를 가지고 m \leq n 이라고 가정해 보자.
    • 패딩된 벡터 x^{p} 의 크기는 n + 2p 이다.
    • 이산 합성곱을 계산하기 위한 실제 공식은 다음과 같이 바뀐다.

y = x * w \to y[i] = \sum_{k=0}^{m-1} x^{p}[i+m-k] w[k]

  • 무한한 인덱스 문제를 해결했다. 둘째 이슈는 i+m-k x 를 인덱싱하는 것이다.
    • x w 가 이 식에서 다른 방향으로 인덱싱한다는 점이 중요하다.
    • 이 때문에 패딩된 후에 x 또는 w 벡터 중 하나를 뒤집어 간단히 점곱으로 계산할 수 있다.
  • 필터 w 를 뒤집어서 회전된 필터 w^{r} 을 얻었다고 가정해 보자.
    • 점곱 x[i:i+m] \cdot w^{r} 을 계산하면 y[i] 원소 하나가 얻어진다. x[i:i+m] 은 크기가 m x 의 패치이다.
    • 이 연산이 모든 출력 원소를 얻기 위해 슬라이딩 윈도우(sliding window) 방식으로 반복된다.
    • 아래 그림은 x = (3, 2, 1, 7, 1, 2, 5, 4) 이고 w = ({1 over 2}, {3 over 4}, 1, {1 over 4}) 일 때 처음 세 개의 출력 원소를 계산하는 경우를 보여준다.

  • 이 예에서 패딩 크기는 0이다. (p = 0 )
    • 회전된 필터 w^{r} 은 2칸씩 이동한다. 이동하는 양은 스트라이드(stride)라고 하며, 또 하나의 합성곱 하이퍼파라미터이다.  여기서 스트라이드는 2이다. (s = 2 )
    • 스트라이드는 입력 벡터의 크기보다 작은 양수 값이어야 한다.

합성곱에서 제로 패딩의 효과

  • 지금까지 유한한 크기의 출력 벡터를 얻기 위해 합성곱에 제로 패딩을 사용했다.
    • 기술적으로 p \geq 0 인 어떤 패딩도 적용할 수 있다. p 값에 따라 x 에서 경계에 있는 셀은 중간 셀과 다르게 처리된다.
  • n=5, m=3, p=0 인 경우를 생각해 보자. x[0] 은 하나의 출력 원소를 계산하는데만 사용된다. (예컨대 y[0] )
    • 반면 x[1] 은 두 개의 출력 원소를 계산하는데 사용된다. (y[0], y[1] )
    • x 원소를 이렇게 다르게 취급하기 때문에 가운데 있는 [2]x 가 대부분의 계산에 사용되어 강조되는 효과를 낸다.
    • 여기서는 p=2 를 사용하면 이 문제를 피할 수 있다.
    • x 의 각 원소가 세 개의 y 원소 계산에 참여한다.
  • 또 출력 y 크기는 사용한 패딩 방법에 따라 달라진다. 실전에서 자주 사용하는 세 개의 패딩 방법은 풀(full) 패딩, 세임(same) 패딩, 밸리드(valid) 패딩이다.
    • 풀 패딩은 패딩 파라미터 p p=m-1 로 설정한다. 풀 패딩은 출력 크기를 증가시키기 때문에 합성곱 신경망 구조에서는 거의 사용되지 않는다.
    • 세임 패딩은 출력 크기가 입력 벡터 x 와 같아야 할 때 사용한다. 이때 패딩 파라미터 p 는 입력과 출력 크기가 동일해야 하기 때문에 필터 크기에 따라 결정된다.
    • 마지막으로 밸리드 패딩 합성곱은 p=0 인 경우를 말한다. (패딩 없음)
  • 아래 그림은 세 개의 패딩 모드를 보여준다. 입력은 5×5 픽셀, 커널은 3×3 크기, 스트라이드는 1인 경우이다.

  • 합성곱 신경망에서 가장 많이 사용되는 패딩 방법은 세임 패딩이다. 다른 패딩 방식에 비해 장점은 세임 패딩이 입력 이미지나 텐서의 높이와 너비를 유지시킨다는 것이다. 이 때문에 네트워크 구조를 설계하기 쉽다.
  • 풀 패딩이나 세임 패딩에 비해 밸리드 패딩의 단점은 신경망에 층이 추가될수록 점진적으로 텐서 크기가 줄어든다는 것이다. 이는 신경망 성능을 나쁘게 만들 수 있다.
  • 실전에서는 세임 패딩으로 너비와 높이를 유지시키고 풀링에서 크기를 감소시킨다. 풀 패딩은 입력보다 출력 크기를 증가시키므로 경계 부분의 영향을 최소화하는 것이 중요한 신호 처리 애플리케이션에서 보통 사용된다.
    • 딥러닝에서는 경계 부분의 영향이 크지 않기 때문에 풀 패딩이 거의 사용되지 않는다.

합성곱 출력 크기 계산

  • 합성곱 출력 크기는 입력 벡터 위를 필터 w 가 이동하는 전체 횟수로 결정된다.
    • 입력 벡터의 크기는 n 이고 필터의 크기는 m , 패딩이 p , 스트라이드가 s x * w 출력 크기는 다음과 같이 계산된다.

o = \lfloor {n + 2p - m \over s} \rfloor + 1

  • 여기서 \lfloor \cdot \rfloor 는 버림 연산을 나타낸다.
  • 입력 벡터 크기가 10이고 합성곱 커널 크기가 5, 패딩이 2, 스트라이드가 1일 때 출력 크기는 다음과 같다.

n=10, m=5, p=2, s=1 \to o = \lfloor {10 + 2 \times 2 - 5 \over 1} \rfloor + 1= 10

  • 커널 크기가 3이고 스트라이드가 2이면 같은 입력 벡터일 때 출력 크기는 다음과 같다.

n=10, m=3, p=2, s=2 \to o = \lfloor {10 + 2 \times 2 - 3 \over 2} \rfloor + 1 = 6

  • 1차원 합성곱의 계산 방법을 익히기 위해 단순하게 구현해 보고 이 결과를 numpy.convolve 함수와 비교해 보자.
import numpy as np

def conv1d(x, w, p=0, s=1):
   w_rot = np.array(w[::-1])
    x_padded = np.array(x)

    if p > 0:
       zero_pad = np.zeros(shape=p)
        x_padded = np.concatenate([zero_pad, x_padded, zero_pad])
       res = []

       for i in range(0, int(len(x)/s), s):
           res.append(np.sum(x_padded[i:i+w_rot.shape[0]] * w_rot))       

        return np.array(res)

x = [1, 3, 2, 4, 5, 6, 1, 3]
w = [1, 0, 3, 1, 2]

print('Conv1d 구현:', conv1d(x, w, p=2, s=1))

### 결과
# Conv1d 구현: [ 5. 14. 16. 26. 24. 34. 19. 22.]

print('넘파이 결과:', np.convolve(x, w, mode='same'))

### 결과
# 넘파이 결과: [ 5 14 16 26 24 34 19 22]

2D 이산 합성곱 수행

  • 앞서 배운 개념은 2차원으로 쉽게 확장 가능하다. m_{1} \leq n_{1} 이고 m_{2} \leq n_{2} 인 행렬 X_{n_{1} \times n_{2}} 와 필터 행렬 W_{m_{1} \times m_{2}} 같은 2차원 입력을 다룰 떄 X W 의 2D 합성곱 결과는 행렬 Y = X * W 가 된다.

Y = X * W \to Y[i, j] = \sum_{k_{1} = -\infty}^{+\infty} \sum_{k_{2} = -\infty}^{+\infty} X[i-k_{1}, j-k_{2}] W[k_{1}, k_{2}]

  • 차원 하나를 제거하면 남은 공식이 이전의 1D 합성곱과 동일하다.
  • 사실 제로 패딩, 필터 행렬의 회전, 스트라이드 같은 이전에 언급한 모든 기법도 2D 합성곱에 적용할 수 있다. 양쪽 차원에 독립적으로 확장된다.
  • 다음 예는 패딩 p = (1, 1) 과 스트라이드 s = (2, 2) 일 때 입력 행렬 X_{3 \times 3} 과 커널 행렬 W_{3 \times 3} 사이의 2D 합성곱 계산을 보여준다.
    • 여기서는 입력 행렬의 네 면에 0이 한줄씩 추가되어 X_{5 \times 5}^{padded} 행렬을 만든다.

  • 필터를 뒤집으면 다음과 같다.

W^{r} = \left[ \begin{array}{rrr} 0.5 & 1 & 0.5 \\ 0.1 & 0.4 & 0.3 \\ 0.4 & 0.7 & 0.5 \end{array} \right]

  • 이 변환은 전치 행렬과 다르다. 넘파이에서 필터를 역전시키려면 W_rot = W[::-1, ::-1] 처럼 쓴다. 그 다음 패딩된 입력 행렬 X^{padded} 를 따라 슬라이딩 윈도우처럼 역전된 필터를 이동하면서 원소별 곱의 합을 계산한다.
    • 아래 그림에 \odot 연산자로 표기했다.

  • 결과값 Y 는 2×2 행렬이다.
  • 단순한 알고리즘을 사용하여 2D 합성곱도 구현해 보겠다. scipy.signal 패키지는 2D 합성곱을 계산할 수 있는 scipy.signal.convolve2d 함수를 제공한다.
import numpy as np
import scipy.signal

def conv2d(X, W, p=(0, 0), s=(1, 1)):
    W_rot = np.array(W)[::-1, ::-1]
    X_orig = np.array(X)
   n1 = X_orig.shape[0] + 2*p[0]
   n2 = X_orig.shape[1] + 2*p[1]
   X_padded = np.zeros(shape=(n1, n2))
    X_padded[p[0]:p[0]+X_orig.shape[0], p[1]:p[1]+X_orig.shape[1]] = X_orig

    res = []
   for i in range(0, int((X_padded.shape[0] - W_rot.shape[0])/s[0])+1, s[0]):
        res.append([])

        for j in range(0, int((X_padded.shape[1] - W_rot.shape[1])/s[1])+1, s[1]):
            X_sub = X_padded[i:i+W_rot.shape[0], j:j+W_rot.shape[1]]
           res[-1].append(np.sum(X_sub * W_rot))   

    return (np.array(res))

X = [[1, 3, 2, 4], [5, 6, 1, 3], [1, 2, 0, 2], [3, 4, 3, 2]]
W = [[1, 0, 3], [1, 2, 1], [0, 1, 1]]

print('Conv2d 구현:\n', conv2d(X, W, p=(1, 1), s=(1,1)))

### 결과
# Conv2d 구현:
# [[11. 25. 32. 13.]
# [19. 25. 24. 13.]
# [13. 28. 25. 17.]
# [11. 17. 14. 9.]]

print('사이파이 결과:\n', scipy.signal.convolve2d(X, W, mode='same'))

### 결과
# 사이파이 결과:
# [[11 25 32 13]
# [19 25 24 13]
# [13 28 25 17]
# [11 17 14 9]]

서브샘플링

  • 서브샘플링은 전형적인 두 종류의 풀링 연산으로 합성곱 신경망에 적용된다. 최대 풀링(max-pooing)과 평균 풀링(mean-pooling 또는 average-pooling)이다.
    • 풀링 층은 보통 P_{n_{1} \times n_{2}} 로 표시한다.
    • 아래 첨자는 최댓값과 평균 연산이 수행되는 이웃한 픽셀 크기이다. (차원별로 인접 픽셀 개수)
    • 이런 이웃 픽셀 개수를 풀링 크기라고 한다.
  • 아래 그림에 이 연산을 나타냈다. 최대 풀링은 이웃한 픽셀에서 최댓값을 취하고 평균 풀링은 픽셀의 평균을 계산한다.

  • 풀링의 장점은 두가지 이다.
    • 풀링(최대 풀링)은 일종의 지역 불변경을 만든다. 국부적인 작은 변화가 최대 풀링의 결과를 바꾸지 못한다는 의미이다. 결국 입력 데이터에 있는 잡음에 좀 더 안정적인 특성을 생성한다. 아래에서 보듯 두 개의 다른 입력 행렬 X_{1} X_{2} 가 같은 결과를 만든다.
    • 풀링은 특성 크기를 줄이므로 계산 효율성을 높인다. 또 특성 개수가 줄어들면 과대적합도 감소된다.

기본 구성 요소를 사용하여 심층 합성곱 신경망 구성

  • 지금까지 합성곱 신경망의 기본 구성 요소를 배웠다. 이 장에서 설명한 개념들은 전통적인 다층 신경망보다 아주 어렵지 않다. 일반적인 신경망에서 가장 중요한 연산은 행렬-벡터 곱셈이다.
  • 예컨대 행렬-벡터 곱셈을 사용하여 활성화 함수의 입력(또는 최종 입력) a = Wx + b 을 계산한다.
    • 여기서 x 는 픽셀을 나타내는 열 벡터고, W 는 입력 픽셀과 각 은닉 유닛을 연결하는 가중치 행렬이다.
    • 합성곱 신경망에서 이 연산은 합성곱 연산 A = W * X + b 로 바뀐다. X 는 높이 x 너비의 픽셀을 나타내는 행렬이다.
    • 두 경우 모두 은닉 유닛의 활성화 출력 H = \phi (A) 를얻기 위해 활성화 함수에 입력으로 전달된다. 여기서 \phi 는 활성화 함수이다.
    • 이전 절에서 설명한 것처럼 풀리응로 표현되는 서브샘플링도 합성곱 신경망의 구성 요소 중 하나이다.

여러 개의 입력 또는 컬러 채널 다루기

  • 합성곱 층의 입력 샘플에는 N_{1} \times N_{2} 차원 (예컨대 이미지의 높이와 너비 픽셀)인 하나 이상의 2D 배열 또는 행렬이 포함될 수 있다.
    • 이런 N_{1} \times N_{2} 행렬을 채널(channel)이라고 한다.
    • 여러 개의 채널을 합성곱 층 입력에 사용하기 때문에 랭크 3 텐서 또는 3차원 배열 X_{N_{1} \times N_{2} \times C_{in}} 을 사용해야 한다.
    • 여기서 C_{in} 이 입력 채널 크기이다.
  • 예컨대 CNN의 첫 번째 층에 입력되는 이미지를 생각해 보자. RGB 모드의 컬러 이미지라면 C_{in} = 3 이다. (RGB의 빨간색, 초록색, 파란색 채널)
    • 이미지가 그레이스케일(grayscale)이라면 흑백의 픽셀 강도를 가진 하나의 채널만 있으므로 C_{in} = 1 이다.
  • 합성곱 연산에서 여러 개의 입력 채널을 어떻게 다룰 수 있을까?
    • 해답은 간단하다. 각 채널별로 합성곱 연산을 수행하고 행렬 덧셈으로 결과를 합친다.
    • 채널 (c)별 합성곱은 개별적인 커널 행렬 W[:,:,c] 를 사용한다.
    • 활성화 함수에 입력되는 결과값은 다음 공식으로 계산된다.

  • 최종 결과 h 를 특성 맵이라고 한다.
    • 보통 CNN의 합성곱 층은 하나 이상의 특성 맵을 만든다.
    • 여러 개의 특성 맵을 사용하면 커널 텐서는 width \times height \times C_{in} \times C_{out} 으로 4차원이 된다.
    • 너비와 높이는 커널의 크기고 C_{in} 은 입력 채널의 개수, C_{out} 은 출력 특성 맵의 개수이다.
    • 이전 공식에 출력 특성 맵의 개수를 포함시키면 아래와 같다.

  • 아래 그림에 나온 합성곱 층과 풀링 층이 포함된 예제를 통해 신경망의 합성곱 계산을 정리하겠다.
    • 이 예는 입력 채널이 3개이다. 커널 텐서는 4차원이다. 각 커널 행렬은 m_{1} \times m_{2} 크기고 입력 채널에 한 개씩 세 개 이다.
    • 이런 텐서가 다섯 개의 출력 특성 맵을 만들기 위해 다섯 개가 있다.
    • 마지막으로 특성 맵을 서브샘플링하기 위해 풀링 층이 있다.
    • 전체 구조는 아래 그림과 같다.

드롭아웃으로 신경망 규제

  • 일반적인 (완전 연결) 신경망 또는 CNN 중 어떤 것을 사용하든지 네트워크 크기를 결정하는 것은 항상 어려운 문제이다. 어느 정도 좋은 성능을 얻으려면 가중치 행렬 크기와 층 개수를 튜닝해야 한다.
    • 파라미터 개수가 비교적 적은 네트워크는 용량이 작기 때문에 과소적합되기 쉽다. 이는 복잡한 데이터셋에 내재된 구조를 학습할 수 없기 때문에 성능이 나빠진다.
    • 반면 아주 큰 네트워크는 과대적합될 가능성이 많다. 이런 네트워크가 훈련 데이터를 외워 버리면 훈련 세트에서는 잘 작동하지만 테스트 데이터에서는 나쁜 성능을 낼 것이다.
    • 실제 머신 러닝 문제를 다룰 때는 얼마나 네트워크가 커야 하는지 사전에 알 수 없다.
  • 이 문제를 해결하기 위한 한 가지 방법은 다음과 같다.
    • 먼저 훈련 세트에서 잘 동작하도록 비교적 큰 용량의 네트워크를 구축한다 (실제로 필요한 것보다 좀 더 큰 용량을 선택한다)
    • 그 다음 과대적합을 막기 위해 한 개 이상의 규제 방법을 적용하여 별도의 테스트 세트 같은 새로운 데이터에서 일반화 성능을 높인다.
    • 널리 사용되는 규제 방법은 L2 규제이다.
  • 최근에 드롭아웃(dropout)이라는 새로운 규제 기법이 (심층) 신경망을 규제하는데 매우 뛰어나다는 것이 밝혀졌다.
    • 드롭아웃을 앙상블 모델의 (평균적인) 조합으로 생각할 수 있다. 앙상블 학습에서는 독립적으로 여러 개의 모델을 훈련 시킨다.
    • 예측을 할 때는 훈련된 모델을 모두 사용하여 결정한다. 여러 개의 모델을 훈련하고 출력을 모아 평균으 ㄹ내는 작업은 계산 비용이 비싸다.
    • 드롭아웃은 많은 모델을 동시에 훈련하고 테스트나 예측 시에 평균을 효율적으로 계산하는 효과적인 방법을 제공한다.
  • 드롭아웃은 보통 깊은 층의 은닉 유닛에 적용한다. 신경망의 훈련 단계에서 반복마다 P_{drop} 확률로 은닉 유닛의 일부가 랜덤하게 꺼진다 (또는 P_{keep} = 1 - P_{drop} 확률만큼 랜덤하게 켜진다)
    • 드롭아웃 확률은 사용자가 지정해야 하며 보통 p = 0.5 를 사용한다. 입력 뉴런의 일부를 끄면 남은 뉴런에 연결된 가중치가 누락된 뉴런 비율만큼 증가된다.
  • 랜덤한 드롭아웃의 영향으로 네트워크는 데이터에서 여분의 표현을 학습한다. 따라서 네트워크가 일부 은닉 유닛의 활성화 값에 의존할 수 없다.
    • 훈련 과정에서 언제든지 은닉 유닛이 꺼질 수 있기 때문이다.
    • 이는 네트워크가 데이터에서 더 일반적이고 안정적인 패턴을 학습하게 만든다.
  • 랜덤한 드롭아웃은 과대적합을 효과적으로 방지한다. 아래 그림은 훈련 단계에서 p = 0.5 의 확률로 드롭아웃을 적용하는 사례를 보여준다.
    • 절반의 뉴런은 랜덤하게 활성화 되지 않는다.
    • 예측할 때는 모든 뉴런이 참여하여 다음 층의 활성화 함수 입력을 계산한다.

  • 여기서 보듯이 훈련 단계에서만 유닛이 랜덤하게 꺼진다는 것이 중요하다.
    • 평가 단계에서는 모든 은닉 유닛이 활성화 되어야 한다 (즉 P_{drop} = 0 이고 P_{keep} = 1 이다)
    • 훈련과 예측 단계의 전체 활성화 값의 스케일을 맞추기 위해 활성화된 뉴런 출력이 적절히 조정되어야 한다. (예컨대 훈련할 때 드롭아웃 확률이 p = 0.5 라면 테스트할 때 활성화 출력을 절반으로 낮춘다)
  • 실전에서 예측을 만들 때 활성화 값의 출력을 조정하는 것은 불편하기 때문에 텐서플로나 다른 라이브러리들은 훈련 단계의 활성화를 조정한다 (예컨대 드롭아웃 확률이 p = 0.5 라면 활성화 함수의 출력을 2배로 높인다)
  • 드롭아웃과 앙상블 학습간에 어떤 관계가 있을까? 반복마다 다른 은닉 유닛을 끄기 때문에 다른 모델을 훈련하는 효과를 낸다.
    • 이런 모델을 모두 훈련시킨 후 유지 확률을 1로 설정하고 모든 은닉 유닛을 사용한다.
    • 이는 모든 은닉 유닛으로부터 평균적인 활성화 출력을 얻는다는 의미가 된다.

텐서플로를 사용하여 심층 합성곱 신경망 구현

다층 CNN 구조

  • 여기서 구현할 네트워크는 아래 그림에 나타나 있다.
    • 입력은 28×28 크기의 그레이스케일 이미지이다.
    • 채널 개수와 입력 이미지의 배치를 생각하면 입력 텐서의 차원은 batchsize x 28 x 28 x 1이 된다.
    • 입력 데이터 5×5 크기의 커널을 가진 두 개의 합성곱 층을 지난다. 첫 번째 합성곱은 32개의 특성 맵을 출력하고 두 번째는 64개의 특성 맵을 출력한다. 각 합성곱 층 다음에는 서브샘플링으로 최대 풀링 연산이 뒤따른다.
    • 그 다음 완전 연결 층의 출력이 최종 소프트맥스 층인 두 번째 완전 연결 층으로 전달된다.

  • 각 층의 텐서 차원은 다음과 같다.
    • 입력: batchsize x 28 x 28 x 1
    • 합성곱_1: batchsize x 24 x 24 x 32
    • 풀링_1: batchsize x 12 x 12 x 32
    • 합성곱_2: batchsize x 8 x 8 x 64
    • 풀링_2: batchsize x 4 x 4 x 64
    • 완전 연결_1: batchsize x 1024
    • 완전 연결과 소프트맥스 층: batchsize x 10

데이터 적재와 전처리

  • 13장에서 load_mnist 함수를 사용하여 MNIST 손글씨 데이터셋을 읽었는데, 여기서도 다음과 같은 과정을 반복하겠다.
X_data, y_data = mn.load_mnist('./mnist/', kind='train')
X_test, y_test = mn.load_mnist('./mnist/', kind='t10k')

count = 50000
X_train, y_train = X_data[:count,:], y_data[:count]
X_valid, y_valid = X_data[count:, :], y_data[count:]
  • 훈련 성능을 높이고 최적 값에 잘 수렴하려면 데이터를 정규화해야 한다.
    • 훈련 데이터의 특성마다 평균을 계산하고 모든 특성에 걸쳐 표준 편차를 계산한다.
    • 각 특성 별로 표준 편차를 계산하지 않는 이유는 MNIST 같은 이미지 데이터셋에 있는 일부 특성(픽셀) 값은 모든 이미지에서 동일하게 255이기 때문이다.
    • 모든 샘플에서 고정된 값이면 변동이 없고 표준 편차가 0이 되므로 0-나눗셈 에러가 발생한다. 이런 이유로 X_train 전체의 표준 편차를 계산하기 위해 np.std 함수의 axis 매개변수를 지정하지 않았다.
mean_vals = np.mean(X_train, axis=0)
std_val = np.std(X_train)

X_train_centered = (X_train - mean_vals) / std_val
X_valid_centered = (X_valid - mean_vals) / std_val
X_test_centered = (X_test - mean_vals) / std_val
  • 여기서는 이미지를 2차우너 배열로 읽어 들였다. 샘플마다 하나의 행을 차지하며 784개의 픽셀에 해당하는 ㅇ려이 있다.
    • 합성곱 신경망에 데이터를 주입하려면 784개의 행을 원본 이미지의 차원과 동일한 28 x 28 x 1 크기로 바꾸어야 한다.
    • MNIST 이미지는 흑백 이미지이기 때문에 마지막 컬러 채널이 의미가 없지만 합성곱 연산에서는 마지막 채널 차원이 필요하다.
  • 넘파이의 reshape 메서드를 사용하여 훈련 데이터, 검증 데이터, 테스트 데이터의 차원을 다음과 같이 변경하겠다.
    • 첫 번째 차원은 샘플 차원이므로 변경하지 않고 나머지 차원에 따라 자동으로 결정된다.
X_train_centered = X_train_centered.reshape((-1, 28, 28, 1))
X_valid_centered = X_valid_centered.reshape((-1, 28, 28, 1))
X_test_centered = X_test_centered.reshape((-1, 28, 28, 1))
  • 그 다음 13장에서 했던 것처럼 클래스 레이블을 원-핫 인코딩으로 변경하겠다. to_categorical 함수를 사용하여 변환한다.
from tensorflow.keras.utils import to_categorical

y_train_onehot = to_categorical(y_train)
y_valid_onehot = to_categorical(y_valid)
y_test_onehot = to_categorical(y_test)
  • 훈련 데이터를 원하는 형태로 변환했기 때문에 CNN을 구현할 준비가 되었다.

텐서플로 tf.keras API로 CNN 구성

  • 텐서플로에서 CNN을 구현하기 위해 tf.keras API로 합성곱 네트워크를 구현해 보겠다.
    • 먼저 tf.keras의 하위 모듈 중 layers, models를 임포트한다.
    • 그리고 13장에서 만들었던 것처럼 Sequential 모델을 만든다. 이전에는 완전 연결 층만 추가했지만, 이 예제에서는 합성곱을 위한 층을 추가한다.
from tensorflow.keras import layers, models

model = models.Sequential()
  • layers 모듈 아래에는 Dense 층 외에 다양한 층이 이미 구현되어 있다.
    • 대표적으로 2차원 합성곱을 위한 Conv2D 클래스가 있다. 또 드롭아웃을 위한 Dropout 클래스와 최대 풀링을 위한 MaxPool2D, 평균 풀링을 위한 AveragePool2D 클래스를 제공한다.
  • 먼저 Conv2D 클래스를 모델에 추가해 보겠다. 이전 장에서 Dense 층을 추가했던 것과 비슷하게 Conv2D 클래스의 객체를 모델의 add 메서드에 전달한다.
model.add(layers.Conv2D(32, (5, 5), padding='valid', activation='relu', input_shape=(28, 28, 1)))
  • Conv2D 클래스의 첫 번째 매개변수는 필터 개수이고 두 번째는 필터 크기이다. 그림 15-10에 나타난 CNN 네트워크 구조처럼 첫 번째 합성곱 층은 5×5 크기의 필터를 32개 가진다.
  • padding 매개변수에는 ‘valid’ 패딩을 지정한다. 세임 패딩을 선택하려면 ‘same’으로 지정한다. 대소문자는 구분하지 않는다.
    • padding 매개변수의 기본값이 ‘valid’이므로 설정하지 않아도 된다.
  • 스트라이드를 설정하는 strides 매개변수는 정수 또는 정수 두 개로 이루어진 튜플로 지정한다. 튜플일 경우 높이와 너비 방향의 스트라이드를 각각 다르게 지정할 수 있다.
    • 기본값은 1로 높이와 너비 방향으로 1칸씩 필터를 이동시킨다. 여기서는 strides를 지정하지 않았으므로 기본값을 사용한다.
  • 활성화 함수는 Dense 층과 마찬가지로 activation 매개변수에서 지정한다. 여기서는 최근 이미지 분야에서 자주 사용되는 렐루(ReLu) 활성화 함수를 선택했다.
  • kernel_initializer와 bias_initializer 매개변수는 따로 지정하지 않았으므로 기본값으로 설정도니다.
    • kernel_initializer 매개변수는 세이비어(또는 글로럿) 초기화 방식인 ‘glorot_uniform’이 되고 bias_initializer는 ‘zeros’가 사용된다.
  • 마지막으로 모델에 추가되는 첫 번째 층이므로 입력 크기를 input_shape 매개변수에 지정한다. 여기서도 첫 번째 배치 차원을 제외하고 28 x 28 x 1 크기를 지정했다.
  • 앞서 합성곱의 출력을 계산하는 공식을 사용하여 출력 크기를 계산해 보겠다. 입력 크기는 28, 필터 크기는 5, 패딩은 0이고 스트라이드는 1이다.

o = {n + 2p - m \over s} + 1 = {28 + 0 - 5 \over 1} + 1 = 24

  • 이미지를 하나 생각해 보자. 28 x 28 x 1 크기의 이미지가 첫 번째 합성곱 연산을 거쳐 24 x 24 x 1 크기로 바뀐다. 첫 번째 층의 필터 개수가 32개이므로 최종적으로 출려되는 특성 맵의 크기는 24 x 24 x 32가 된다.
    • 이 층의 전체 가중치 개수는 5 x 5 x 1 크기의 필터가 32개 있고 절편이 32개 있으므로 5 x 5 x 32 + 32 = 832개 이다.
  • 그 다음 추가할 층은 최대 풀링 층이다. 코드는 다음과 같다.
model.add(layers.MaxPool2D((2, 2)))
  • 풀링 층의 첫 번째 매개변수(pool_size)는 풀링 크기로 높이와 너비를 튜플로 지정한다.
    • 풀링 크기의 기본값은 (2, 2) 이다.
  • 두 번째 매개변수는 스트라이드(strides)로 기본값은 None이다.
    • 스트라이드가 none이면 풀링 크기를 사용하여 겹치지 않도록 풀링된다.
    • 보통 풀링에서 스트라이드를 지정하는 경우는 드물다. 여기서도 스트라이드는 따로 지정하지 않았다.
  • (2, 2) 크기로 풀링했기 때문에 풀링 층을 통과한 특성 맵의 크기는 높이와 너비가 절반으로 줄어든다. 하지만 특성 맵의 개수는 변화가 없다. 따라서 최종적으로 출력되는 특성 맵의 차원은 12 x 12 x 32가 된다.
    • 또 풀링 층은 가중치가 없다는 점도 잊지 말자
  • 두 번째 합성곱 층을 추가할 차례이다. 필터 개수만 제외하고 첫 번째 합성곱의 매개변수와 동일하다. 여기서는 64개의 필터를 사용하겠다.
model.add(layers.Conv2D(64, (5, 5), padding='valid', activation='relu'))
  • 두 번째 합성곱 층의 출력 크기를 계산해 보자. 입력 크기는 12, 필터 크기는 5, 패딩은 0이고 스트라이드는 1이다.

o = {n + 2p - m \over s} + 1 = {12 + 0 - 5 \over 1} + 1 = 8

  • 12 x 12 x 32 크기의 이미지가 두 번째 합성곱 연산을 거쳐 8 x 8 x 1 크기로 바뀐다. 두 번째 층의 필터 개수가 64개이므로 최종적으로 출력되는 특성 맵의 크기는 8 x 8 x 64가 된다.
  • 두 번째 합성곱 층의 필터 크기는 5 x 5 x 1이 아니라 5 x 5 x 32이다. 측, 채널 방향으로는 필터가 이동하지 않고 전체 채널이 한 번에 합성곱에 참여한다.
    • Conv2D의 필터 크기를 (5, 5)로 지정했지만, tf.keras API는 똑똑하게 이전 층의 출력 채널에 맞추어 필터를 생성한다.
  • 그럼 두 번째 층의 가중치 개수는 얼마일까? 5 x 5 x32 크기의 필터가 64개 있고 절편이 64개 있다. 따라서 5 x 5 x 32 x 64 + 54 = 51,264개이다.
  • 이제 두 번째 풀링 층을 추가해보자. 코드는 첫 번째 풀링 층과 동일하다.
model.add(layers.MaxPool2D((2, 2)))
  • 여기서도 (2, 2) 크기로 풀링했기 때문에 특성 맵의 크기는 높이와 너비가 절반으로 줄어든다. 특성 맵의 개수는 변화가 없으므로 최종적으로 출력되는 특성 맵의 차원은 4 x 4 x 64가 된다.
  • 다음으로 완전 연결 층인 Dense 층에 연결하기 위해 4 x 4 x 64 차원의 텐서를 일렬로 펼쳐야 한다. 케라스 API는 이런 작업을 위해 Flatten 클래스를 제공한다.
    • 이 클래스는 매개변수가 필요하지 않다. 모델에 추가하면 이전 층의 출력을 일렬로 펼치는 작업을 한다. 당연하게 학습되는 가중치도 없다.
model.add(layers.Flatten())
  • 4 x 4 x 64 크기의 텐서를 펼쳤으므로 1,024차원의 텐서가 되었다. 이를 1,024개의 유닛을 가진 완전 연결 층에 연결하겠다. 이 층의 활성화 함수도 렐루 함수를 사용한다.
model.add(layers.Dense(1024, activation='relu'))
  • Dense 층의 kernel_initializer와 bias_initializer도 지정하지 않으면 기본값인 ‘glorot_uniform’과 ‘zeros’로 설정된다.
  • 이 층의 가중치 개수는 1,024 텐서를 1,024개의 유닛에 완전 연결했으므로 1,024 x 1,024개와 절편 1,024개를 더하면 1,049,600개가 된다. 두 개의 합성곱 층에서 사용한 가중치를 합한 것보다 훨씬 많다.
  • 마지막 층에 연결하기 전에 드롭아웃 층을 추가하겠다. 케라스의 Dropout 클래스도 가중치를 가지지 않는다.
model.add(layers.Dropout(0.5))
  • Dropout 클래스에는 유닛을 끌 확률을 매개변수로 지정한다. 편리하게도 fit 메서드에서만 드롭아웃이 적용된다. 테스트나 평가를 위해 따로 모델을 구성할 필요는 없다.
  • 마지막 층은 열 개의 손글씨 숫자에 대한 확률을 출력해야 하므로 열 개의 유닛을 가진 완전 연결 층이다. 다중 분류 문제를 위한 활성화 함수는 소프트맥스 함수이므로 activation 매개변수를 ‘softmax’로 설정한다.
model.add(layers.Dense(10, activation='softmax'))
  • 이 층의 가중치 개수는 1,024개의 이전 Dense 층 출력을 열 개의 유닛에 연결했으므로 절편과 합쳐서 1,024 x 10 + 10 = 10,250이 된다.
  • 합성곱 신경망은 전형적으로 이렇게 마지막에 한 개 이상의 완전 연결 층으로 연결된다. 최종 출력 층의 유닛 개수는 클래스 레이블의 개수와 맞추어야 한다.
  • 모델의 summary 메서드를 호출하여 합성곱 신경망의 구성을 확인해 보자.
model.summary()

### 결과
# Model: "sequential"
# _________________________________________________________________
# Layer (type) Output Shape Param #
# =================================================================
# conv2d (Conv2D) (None, 24, 24, 32) 832
# _________________________________________________________________
# max_pooling2d (MaxPooling2D) (None, 12, 12, 32) 0
# _________________________________________________________________
# conv2d_1 (Conv2D) (None, 8, 8, 64) 51264
# _________________________________________________________________
# max_pooling2d_1 (MaxPooling2 (None, 4, 4, 64) 0
# _________________________________________________________________
# flatten (Flatten) (None, 1024) 0
# _________________________________________________________________
# dense (Dense) (None, 1024) 1049600
# _________________________________________________________________
# dropout (Dropout) (None, 1024) 0
# _________________________________________________________________
# dense_1 (Dense) (None, 10) 10250
# =================================================================
# Total params: 1,111,946
# Trainable params: 1,111,946
# Non-trainable params: 0

합성곱 신경망 모델 훈련

  • 이제 모델을 컴파일할 차례이다.
    • 다중 분류 작업이므로 손실 함수는 이전 장에서 사용했던 것처럼 categorical_crossentropy를 사용한다.
    • 옵티마이저는 adam을 사용하겠다. 또 손실 점수와 더불어 정확도 값을 계산하기 위해 metrics 매개변수에 ‘acc’를 추가했다.
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['acc'])
  • 13장에서 보았던 것처럼 모델을 훈련할 때 최선의 가중치를 저장하기 위해 ModelCheckpoint 콜백을 사용하겠다. 또 텐서보드를 사용하여 시각화하기 위해 TensorBoard 콜백도 추가하겠다.
import time
from tensorflow.keras.callbacks import ModelCheckpoint, TensorBoard

callback_list = [ModelCheckpoint(filepath='cnn_checkpoint.h5', monitor='val_loss', save_best_only=True), TensorBoard(log_dir='logs/{}'.format(time.asctime()))]

# 위 코드에서 tensorboard가 폴더를 못 만든다면 아래처럼 시간을 빼고 돌리면 일단 실행은 된다. 윈도우에서 폴더 만드는 게 잘 안되는 것이라 생각 됨.
# callback_list = [ModelCheckpoint(filepath='cnn_checkpoint.h5', monitor='val_loss', save_best_only=True), TensorBoard()]
  • 체크포인트 콜백은 검증 손실(val_loss)을 모니터링하고 최상의 가중치를 cnn_checkpoint.h5 파일에 저장한다.
  • 텐서보드 콜백은 logs 디렉터리 하위에 서브디렉터리를 만들어 통계를 저장한다.
    • 모델을 여러 번 훈련하는 경우 같은 디렉터리에 데이터가 저장되면 텐서보드에서 그래프를 보기가 불편하므로 실행할 때마다 다른 하위 디렉터리에 저장할 수 있게 time 모듈의 asctime 함수를 사용했다.
  • 이 두 콜백을 연결하여 callback_list를 만들었다. 모델의 fit 메서드를 호출할 때 callbacks 매개변수로 전달하겠다.
history = model.fit(X_train_centered, y_train_onehot, batch_size=64, epochs=20, validation_data=(X_valid_centered, y_valid_onehot), callbacks=callback_list)
  • 훈련 데이터와 검증 데이터는 앞서 준비했던 X_train_centered, y_train_onehot, X_valid_centered, y_valid_onehot을 사용한다.
    • fit 메서드의 batch_size 기본값은 32이다. 즉 32개씩 미니배치를 만들어 네트워크를 훈련한다. 여기서는 64개로 늘렸다.
    • 훈련 세트를 반복하여 학습하는 횟수는 20번으로 지정했다.
  • fit 메서드의 훈련 결과는 다음과 같다.
# Train on 50000 samples, validate on 10000 samples
# Epoch 1/20
# 2020-05-03 11:18:07.989105: I tensorflow/stream_executor/platform/default/dso_loader.cc:44] Successfully opened dynamic library cublas64_100.dll
# 2020-05-03 11:18:08.209138: I tensorflow/stream_executor/platform/default/dso_loader.cc:44] Successfully opened dynamic library cudnn64_7.dll
# 2020-05-03 11:18:09.156252: W tensorflow/stream_executor/cuda/redzone_allocator.cc:312] Internal: Invoking ptxas not supported on Windows
# Relying on driver to perform ptx compilation. This message will be only logged once.
# 2020-05-03 11:18:09.217162: I tensorflow/core/profiler/lib/profiler_session.cc:184] Profiler session started.
# 2020-05-03 11:18:09.221872: W tensorflow/stream_executor/platform/default/dso_loader.cc:55] Could not load dynamic library 'cupti64_100.dll'; dlerror: cupti64_100.dll not found
# 2020-05-03 11:18:09.228706: W tensorflow/core/profiler/lib/profiler_session.cc:192] Encountered error while starting profiler: Unavailable: CUPTI error: CUPTI could not be loaded or symbol could not be found.
# 64/50000 [..............................] - ETA: 22:04 - loss: 2.3739 - acc: 0.07812020-05-03 11:18:09.244288: I tensorflow/core/platform/default/device_tracer.cc:588] Collecting 0 kernel records, 0 memcpy records.
# 2020-05-03 11:18:09.249098: E tensorflow/core/platform/default/device_tracer.cc:70] CUPTI error: CUPTI could not be loaded or symbol could not be found.
# 50000/50000 [==============================] - 4s 87us/sample - loss: 0.1382 - acc: 0.9576 - val_loss: 0.0558 - val_acc: 0.9824
# Epoch 2/20
# 50000/50000 [==============================] - 3s 51us/sample - loss: 0.0516 - acc: 0.9840 - val_loss: 0.0520 - val_acc: 0.9848
# Epoch 3/20
# 50000/50000 [==============================] - 3s 51us/sample - loss: 0.0353 - acc: 0.9891 - val_loss: 0.0417 - val_acc: 0.9883
# ...
# Epoch 20/20
# 50000/50000 [==============================] - 2s 49us/sample - loss: 0.0106 - acc: 0.9972 - val_loss: 0.0793 - val_acc: 0.9909
  • fit 메서드에서 반환된 history 객체를 사용하여 훈련 세트와 테스트 세트에 대한 손실 그래프를 그리면 다음과 같다.
import matplotlib.pyplot as plt

epochs = np.arange(1, 21)

plt.plot(epochs, history.history['loss'])
plt.plot(epochs, history.history['val_loss'])
plt.xlabel('epochs')
plt.ylabel('loss')
plt.show()

  • 정확도 그래프는 다음과 같다.
plt.plot(epochs, history.history['acc'])
plt.plot(epochs, history.history['val_acc'])
plt.xlabel('epochs')
plt.ylabel('accuracy')
plt.show()

  • 텐서보드를 실행하고 브라우저에 http://localhost:6006/에 접속하면 훈련 과정의 손실과 정확도 그래프를 볼 수 있다.
    • 그래프 탭에 타나난 합성곱 신경망의 구조는 아래 그림과 같다.
tensorboard --logdir logs/

  • 훈련된 모델을 사용해서 테스트 세트를 평가하기 전에 모델과 가중치를 저장하자.
model.save('cnn_model.h5')
  • load_model 함수를 사용하여 저장된 모델을 불러 새로운 모델 객체를 만들고 이전에 훈련 과정에서 가장 높은 성능의 가중치가 저장된 체크포인트 파일을 복원한다.
from tensorflow.keras.models import load_model

restored_model = load_model('cnn_model.h5')
restored_model.load_weights('cnn_checkpoint.h5')
  • 복원된 restored_model을 사용하여 테스트 세트에서 평가해 보자.
restored_model.evaluate(X_test_centered, y_test_onehot)

### 결과
# =========] - 1s 62us/sample - loss: 0.0141 - acc: 0.9911
  • 99%가 넘는 예측 정확도가 나오는데 이는 13장에서 피드포워드 신경망으로 얻은 결과보다 훨씬 뛰어난 정확도이다.
  • 테스트 샘플 중 처음 열 개의 예측을 직접 확인해 보면 다음과 같다.
print(np.argmax(restored_model.predict(X_test_centered[:10]), axis=1))

### 결과
# [7 2 1 0 4 1 4 9 5 9]
  • 손글씨 숫자의 예제는 레이블 인덱스와 레이블 값이 같다. 일반적으로 두 값은 다르기 때문에 argmax 함수에서 반환된 인덱스를 사용해서 진짜 클래스 레이블을 구해야 한다.
    • 비교를 위해 처음 열 개의 테스트 레이블을 확인해 보자
print(y_test[:10])

### 결과
# [7 2 1 0 4 1 4 9 5 9]
  • 예측 결과와 모두 동일하다. 이 열 개의 숫자가 어떤 모습인지 확인하기 위해 샘플 이미지를 그려보자.
    • X_test_centered는 행을 따라 샘플이 놓여 있는 2차원 배열이다. 784개의 열을 28 x 28 배열로 바꾸어 그려보자.
fig = plt.figure(figsize=(10,5))

for i in range(10):
   fig.add_subplot(2, 5, i+1)
    plt.imshow(X_test_centered[i].reshape(28, 28))

  • 출력된 결과를 보면 이 모델의 성능이 매우 뛰어나다는 것을 알 수 있다.

활성화 출력과 필터 시각화

  • 첫 번째 합성곱 층의 출력을 이미지로 시각화해 보자.
    • model 객체에 추가한 층은 layers 속성으로 참조할 수 있다. 첫 번째 층의 객체를 추출해서 출력해 보자.
first_layer = model.layers[0]
print(first_layer)

### 결과
# <tensorflow.python.keras.layers.convolutional.Conv2D object at 0x0000021BB186F988>
  • first_layer가 Conv2D 임을 알 수 있다. first_layer의 output 속성을 함수형 API의 출력으로 사용하면 첫 번째 층의 활성화 출력을 얻을 수 있다.
  • 이제 함수형 API를 사용하기 위한 입력이 필요하다. 사실 Sequential 객체에 첫 번째 층을 추가하면 자동으로 model 객체 안에 input 속성이 정의된다. 이를 출력해서 확인해 보자.
print(model.input)

### 결과
# Tensor("conv2d_input:0", shape=(None, 28, 28, 1), dtype=float32)
  • 첫 번째 합성곱 층을 추가할 때 input_shape 매개변수에 입력 크기를 (28, 28, 1)로 지정했다. 이 때문에 model.input의 크기는 배치 차원이 추가되어 (None, 28, 28, 1)이다.
    • 입력과 출력 텐서가 모두 준비되었으므로 이 둘을 연결할 새로운 모델을 만든다. 그 다음 테스트 세트에서 처음 열 개의 샘플을 주입하여 출력을 구하자.
first_activation = models.Model(inputs=model.input, outputs=first_layer.output)
activation = first_activation.predict(X_test_centered[:10])

print(activation.shape)

### 결과
# (10, 24, 24, 32)
  • predict 메서드에서 계산에 사용한 가중치는 앞서 fit 메서드로 훈련한 값이다.
  • 열 개의 테스트 샘플을 입력했으므로 첫 번째 배치 차원은 10이고, 합성곱을 통과하며 높이와 너비가 각각 24 x 24로 줄었다. 첫 번째 합성곱 층의 필터가 32개이므로 마지막 차원이 32가 된다.
  • 열 개의 샘플 중 첫 번째 샘플의 특성 맵 32개를 모두 그리면 아래와 같다.
fig = plt.figure(figsize=(10, 15))

for i in range(32):
   fig.add_subplot(7, 5, i+1)
    plt.imshow(activation[0, :, :, i])

  • 숫자 7의 윤곽을 특성으로 잘 추출한 것으로 보인다.
  • 이번에는 네 번째 숫자의 특성 맵을 그려보자.
fig = plt.figure(figsize=(10, 15))

for i in range(32):
   fig.add_subplot(7, 5, i+1)
  plt.imshow(activation[3, :, :, i])

  • 특성 맵마다 조금씩 다른 숫자 0의 윤곽을 추출하고 있다. 특성 맵의 차이는 필터가 서로 다른 부분을 학습하기 때문이다.
  • 이번 에는 첫 번째 층의 필터를 출력해 보겠다.
    • 합성곱 필터는 합성곱 층의 kernel 속성에 저장되어 있다. 필터의 차원은 (높이, 너비, 입력 채널, 출력 채널)이다.
fig = plt.figure(figsize=(10, 15))

for i in range(32):
   fig.add_subplot(7, 5, i+1)
    plt.imshow(first_layer.kernel[:, :, 0, i])

  • 필터의 밝은 부분이 높은 값을 의미한다. 예컨대 아홉 번째 필터는 수평 에지를 학습하는 것으로 보인다.
    • 이 필터를 사용하여 숫자 7에서 추출된 특성은 수평 부분이 잘 나타나 있다. 반면 0은 수평 부분이 많지 않으므로 추출된 특성에 정보가 많이 담겨 있지 않다.
  • 합성곱 활성화 출력과 필터를 분석하면 중요한 통찰을 얻을 수 이는 경우가 많다. 층이 깊어질수록 합성곱의 활성화 출력 의미를 이해하기 어렵다는 것을 기억하라.

머신 러닝 교과서/ 텐서플로의 구조 자세히 알아보기

텐서플로의 주요 특징

  • 텐서플로는 머신 러닝 알고리즘을 구현하고 실행하기 위한 프로그래밍 인터페이스이며, 확장이 용이하고 다양한 플랫폼을 지원한다.
  • 텐서플로의 주요 특징 중 하나는 여러 개의 GPU를 사용할 수 있는 기능이다. 덕분에 대규모 시스템에서 머신 러닝 모델을 매우 효율적으로 훈련할 수 있다.
  • 텐서플로는 모바일 환경을 지원하기 때문에 모바일 애플리케이션 개발에도 적합하다.

텐서플로의 랭크와 텐서

  • 텐서플로 라이브러리를 사용하여 텐서에 대한 연산과 함수를 계산 그래프로 정의할 수 있다.
    • 텐서는 데이터를 담고 있는 다차원 배열에 대한 일반화된 수학적 용어로 텐서 차원을 일반적으로 랭크(rank)라고 한다.
  • 지금까지는 대부분 랭크 0에서 랭크 2사이의 텐서를 다루었다. 텐서는 더 고차원으로 일반화 될 수 있는데, 여러 개의 컬러 채널을 가진 이미지를 다루기 위해 랭크 3인 입력과 랭크 4인 가중치 텐서를 사용한다.
  • 텐서의 개념을 직관적으로 이해하기 위해 아래 그림을 참고 하자.

텐서의 랭크와 크기를 확인하는 방법

  • tf.rank 함수를 사용하여 텐서 랭크를 확인할 수 있다. tf.rank는 출력으로 텐서를 반환한다.
  • 텐서 랭크 외에도 텐서플로 텐서의 크기를 얻을 수도 있다(넘파이 배열의 크기와 비슷하다)
    • 예컨대 X가 텐서이면 X.get_shape()를 사용하여 크기를 구한다. 이 메서드는 TensorShape이라는 특별한 클래스의 객체를 반환한다.
    • tf.rank 함수와 get_shape 메서드를 사용하는 것은 아래 코드를 참고하라
import tensorflow as tf
import numpy as np

t1 = tf.constant(np.pi)
t2 = tf.constant([1, 2, 3, 4])
t3 = tf.constant([[1, 2], [3, 4]])

r1 = tf.rank(t1)
r2 = tf.rank(t2)
r3 = tf.rank(t3)

s1 = t1.get_shape()
s2 = t2.get_shape()
s3 = t3.get_shape()

print('크기:', s1, s2, s3)
print('랭크:', r1.numpy(), r2.numpy(), r3.numpy())

### 결과
# 크기: () (4,) (2, 2)
# 랭크: 0 1 2
  • t1 텐서는 단순한 스칼라이므로 랭크가 0이다. 크기가 ()
  • t2 벡터는 네 개의 원소를 가지므로 랭크가 1이다. 크기는 원소 하나로 이루어진 튜플 (4, )
  • 2×2 행렬인 t3의 랭크는 2이고 크기는 (2, 2) 튜플이다.

텐서를 다차원 배열로 변환

  • 텐서 변환에 사용되는 여러 연산을 살펴보겠다. 일부 연산들은 넘파이 배열 연산과 매우 비슷하게 작동한다.
    • 랭크 2 이상인 텐서를 다룰 때 전치(tanspose) 같은 변환은 주의를 기울여야 한다.
  • 넘파이는 arr.shape 속성으로 배열 크기를 얻을 수 있다.
import tensorflow as tf
import numpy as np

arr = np.array([[1., 2., 3., 3.5], [4., 5., 6., 6.5], [7., 8., 9., 9.5]])
T1 = tf.constant(arr)
print(T1)

s = T1.get_shape()
print('T1의 크기:', s)
print('T1의 크기:', T1.shape)

T2 = tf.Variable(np.random.normal(size=s))
print(T2)

T3 = tf.Variable(np.random.normal(size=s[0]))
print(T3)

### 결과
# tf.Tensor(
# [[1. 2. 3. 3.5]
# [4. 5. 6. 6.5]
# [7. 8. 9. 9.5]], shape=(3, 4), dtype=float64)
# T1의 크기: (3, 4)
# T1의 크기: (3, 4)
# <tf.Variable 'Variable:0' shape=(3, 4) dtype=float64, numpy=
# array([[ 1.24591289, 0.42771636, -0.23259698, -0.31242843],
# [-0.51809936, -0.89199958, -0.49494362, 0.55808144],
# [-0.11522273, -0.24953113, -0.20146965, 1.40888624]])>
# <tf.Variable 'Variable:0' shape=(3,) dtype=float64, numpy=array([-0.56337014, 0.80102639, 0.84823994])>
  • T2를 만들기 위해 s를 사용했고 T3를 만들 때는 s의 인덱스를 참조했다.
    • get_shape() 메서드는 TensorShape 객체를 반환한다.
  • 텐서플로 1.x에서는 차원 크기를 얻기 위해 TensorShape 객체의 인덱스를 참조하면 Dimension 클래스의 객체가 반환된다.
    • 따라서 다른 텐서를 만들 때 바로 사용할 수 없었고 s.as_list()처럼 TensorShape 객체를 파이썬 리스트로 변환한 후 인덱스를 참조해야 했었다.
    • 텐서플로 2.x에서는 편리하게 TensorShape 객체의 인덱스를 참조하면 스칼라 값이 반환된다.
  • 이제 텐서 크기를 바꾸는 방법을 알아보자. 넘파이에서는 np.reshape이나 arr.reshape을 사용하는데, 텐서플로에서는 tf.reshape 함수를 사용하여 텐서 크기를 바꾼다.
    • 넘파이에서처럼 차원 하나를 -1로 지정할 수 있다. 이 차원 크기는 다른 차원에 지정한 크기를 바탕으로 결정된다.
    • 다음 코드에서 텐서 T1을 랭크 3인 T4와 T5로 변환했다.
T4 = tf.reshape(T1, shape=[1, 1, -1])
print(T4)

T5 = tf.reshape(T1, shape=[1, 3, -1])
print(T5)

### 결과
# tf.Tensor([[[1. 2. 3. 3.5 4. 5. 6. 6.5 7. 8. 9. 9.5]]], shape=(1, 1, 12), dtype=float64)
# tf.Tensor(
# [[[1. 2. 3. 3.5]
# [4. 5. 6. 6.5]
# [7. 8. 9. 9.5]]], shape=(1, 3, 4), dtype=float64)
  • 넘파이에는 배열을 전치할 수 있는 방법이 세 가지가 있다. arr.T, arr.transpose(), np.transpose(arr)이다. 텐서플로에서는 tf.transpose 함수를 사용한다.
    • 일반적인 전치 연산 외에 perm=[…]에 원하는 순서대로 차원을 지정하여 바꿀 수 있다.
T6 = tf.transpose(T5, perm=[2, 1, 0])
print(T6)

T7 = tf.transpose(T5, perm=[0, 2, 1])
print(T7)

### 결과
# tf.Tensor(
# [[[1. ]
# [4. ]
# [7. ]]

# [[2. ]
# [5. ]
# [8. ]]

# [[3. ]
# [6. ]
# [9. ]]

# [[3.5]
# [6.5]
# [9.5]]], shape=(4, 3, 1), dtype=float64)

# tf.Tensor(
# [[[1. 4. 7. ]
# [2. 5. 8. ]
# [3. 6. 9. ]
# [3.5 6.5 9.5]]], shape=(1, 4, 3), dtype=float64)
  • tf.split 함수를 사용하여 다음과 같이 텐서를 더 작은 텐서의 리스트로 나눌 수도 있다.
t5_split = tf.split(T5, num_or_size_splits=2, axis=2)
print(t5_split)

### 결과
# [<tf.Tensor: id=30, shape=(1, 3, 2), dtype=float64, numpy=
# array([[[1., 2.],
# [4., 5.],
# [7., 8.]]])>, <tf.Tensor: id=31, shape=(1, 3, 2), dtype=float64, numpy=
# array([[[3. , 3.5],
# [6. , 6.5],
# [9. , 9.5]]])>]
  • 출력 결과는 더 이상 하나의 텐서가 아니라 텐서의 리스트이다.
  • 마지막으로 또 다른 유용한 변환은 텐서 연결이다. 크기와 dtype이 같은 텐서 리스트가 있다면 tf.concat 함수로 연결하여 하나의 큰 텐서를 만들 수 있다.
t1 = tf.ones(shape=(5, 1), dtype=tf.float32)
t2 = tf.zeros(shape=(5, 1), dtype=tf.float32)
print(t1)
print(t2)

t3 = tf.concat([t1, t2], axis=0)
print(t3)

t4 = tf.concat([t1, t2], axis=1)
print(t4)

### 결과
# tf.Tensor(
# [[1.]
# [1.]
# [1.]
# [1.]
# [1.]], shape=(5, 1), dtype=float32)
# tf.Tensor(
# [[0.]
# [0.]
# [0.]
# [0.]
# [0.]], shape=(5, 1), dtype=float32)
# tf.Tensor(
# [[1.]
# [1.]
# [1.]
# [1.]
# [1.]
# [0.]
# [0.]
# [0.]
# [0.]
# [0.]], shape=(10, 1), dtype=float32)
# tf.Tensor(
# [[1. 0.]
# [1. 0.]
# [1. 0.]
# [1. 0.]
# [1. 0.]], shape=(5, 2), dtype=float32)

텐서플로의 계산 그래프 이해

  • 앞선 장에서 설명한 것처럼 텐서플로 2.x 버전에서는 즉시 실행 모드가 기본으로 활성화 되어 있기 때문에 계산 그래프를 만들지 않고 빠르게 개발과 테스트를 할 수 있다.
    • 모델을 다른 프레임워크와 공유하거나 실행 성능을 높이기 위해 계산 그래프를 만들려면 어떻게 해야 할까?
  • 간단한 예를 통해 텐서플로 2.x 버전에서 계산 그래프를 만든느 방법을 알아보자.
    • 랭크 0텐서 (스칼라) a, b, c 가 있을 때, z = 2 \times (a - b) + c 를 평가한다고 가정하자.
    • 이 계산은 아래 그림과 같은 계산 그래프로 표현할 수 있다.

  • 여기서 계산 그래프는 단순히 노드들의 네트워크이다. 각 노드는 한 개 이상의 입력 텐서를 받고 0개 이상의 출력 텐서를 반환하는 연산으로 표현할 수 있다.
  • 텐서플로 2.x에서는 계산 그래프를 만들지 않고 바로 텐서 z를 계산할 수 있다. a, b, c 는 스칼라이다. 텐서플로 상수로 이들을 정의하겠다.
import tensorflow as tf
import numpy as np

a = tf.constant(1)
b = tf.constant(2)
c = tf.constant(3)

z = 2 * (a - b) + c

print('2 * (a - b) + c => ', z.numpy())

### 결과
# 2 * (a - b) + c => 1
  • 이전 장에서 언급했듯이 텐서플로 1.x 버전은 계산 그래프를 만든 후 세션을 통해 그래프를 실행한다. 텐서플로 1.x 버전에서 계산 그래프를 만들고 실행하는 각 단계를 자세히 정리하면 다음과 같다.
    1. 비어 있는 새로운 계산 그래프를 만든다.
    2. 계산 그래프에 노드(텐서와 연산)을 추가한다.
    3. 그래프를 실행한다.
      1. 새로운 세션을 싲가한다.
      2. 그래프에 있는 변수를 초기화한다.
      3. 이 세션에서 계산 그래프를 실행한다.
  • 위 그림에서처럼 z = 2 \times (a - b) + c 를 평가하는 그래프를 만든다면 tf.graph()를 호출하여 그래프를 만들고 다음과 같이 노드를 추가한다.
## 텐서플로 1.x 방식
g = tf.Graph()

## 그래프에 노드를 추가한다
with g.as_default():
   a = tf.constant(1, name='a')
    b = tf.constant(2, name='b')
    c = tf.constant(3, name='c')
    z = 2 * (a - b) + c

## 그래프를 실행한다
with tf.compat.v1.Session(graph=g) as sess:
    print('2 * (a - b) + c => ', sess.run(z))
  • 이 코드에서 with g.as_default()를 사용하여 그래프 g에 노드를 추가했다.
    • 텐서플로 1.x에서는 명시적으로 그래프를 지정하지 않으면 항상 기본 그래프가 설정된다.
    • 그 다음 tf.Session을 호출하여 세션 객체를 만들고 tf.Session(graph=g)처럼 실행할 그래프를 매개변수로 전달한다.
    • 텐서플로 세션에서 그래프를 적재한 후에는 이 그래프에 있는 노드를 실행시킬 수 있다.
    • 여기서 텐서와 연산을 텐서플로의 계산 그래프 안에 정의했다는 것을 기억해야 한다. 텐서플로 세션은 그래프에 있는 연산을 실행한 후 결과를 평가하고 추출하기 위해 사용된다.
  • 그래프 g에 들어 있는 연산을 출력해 보자. tf.Graph() 객체의 get_operation() 메서드를 사용하면 된다.
print(g.get_operations())

### 결과
# [<tf.Operation 'a' type=Const>, <tf.Operation 'b' type=Const>, <tf.Operation 'c' type=Const>, <tf.Operation 'sub' type=Sub>, <tf.Operation 'mul/x' type=Const>, <tf.Operation 'mul' type=Mul>, <tf.Operation 'add' type=AddV2>]
  • 이번에는 그래프 g의 정의를 출력해 보자. as_graph_deg() 메서드를 호출하면 포맷팅된 문자열로 그래프 정의를 출력한다.
print(g.as_graph_def())

### 결과
# node {
# name: "a"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 1
# }
# }
# }
# }
# node {
# name: "b"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 2
# }
# }
# }
# }
# node {
# name: "c"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 3
# }
# }
# }
# }
# node {
# name: "sub"
# op: "Sub"
# input: "a"
# input: "b"
# attr {
# key: "T"
# value {
# type: DT_INT32
# }
# }
# }
# node {
# name: "mul/x"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 2
# }
# }
# }
# }
# node {
# name: "mul"
# op: "Mul"
# input: "mul/x"
# input: "sub"
# attr {
# key: "T"
# value {
# type: DT_INT32
# }
# }
# }
# node {
# name: "add"
# op: "AddV2"
# input: "mul"
# input: "c"
# attr {
# key: "T"
# value {
# type: DT_INT32
# }
# }
# }
# versions {
# producer: 119
# }
  • 여기까지가 텐서플로 1.x 버전의 계산 그래프였다. 텐서플로 2.x 버전에서는 tf.function 데코레이터를 사용하여 일반 파이썬 함수를 호출 가능한 그래프 객체로 만든다. 마치 tf.Graph와 tf.Session을 합쳐 놓은 것처럼 생각할 수 있다.
    • 위 코드를 tf.function 데코레이터를 사용하여 다시 작성해 보겠다.
@tf.function
def simple_func():
   a = tf.constant(1)
    b = tf.constant(2)
   c = tf.constant(3)
   z = 2 * (a - b) + c
    return z

print('2 * (a - b) + c => ', simple_func().numpy())
  • simple_func() 함수에서 반환되는 텐서를 바로 numpy() 메서드로 변환하여 출력했다. simple_func() 함수는 보통 파이썬 함수처럼 호출할 수 있지만 데코레이터에 의해 객체가 바뀌었다.
print(simple_func.__class__)

### 결과
# <class 'tensorflow.python.eager.def_function.Function'>
  • 파이썬의 다른 데코레이터처럼 다음과 같이 스면 simple_func 객체를 좀 더 이해하기 쉽다.
import tensorflow as tf
import numpy as np

def simple_func():
   a = tf.constant(1, name='a')
    b = tf.constant(2, name='b')
   c = tf.constant(3, name='c')
    z = 2 * (a - b) + c
    return z

simple_func = tf.function(simple_func)
print('2 * (a - b) + c => ', simple_func().numpy())
  • tf.function으로 감싼 함수 안의 연산은 자동으로 텐서플로 그래프에 포함되어 실행된다. 이를 자동 그래프(AutoGraph) 기능이라고 한다.
  • simple_func가 만든 그래프에 있는 연산과 그래프 정의를 확인해 보자
    • 그래프의 정의를 얻으려면 1.x와 마찬가지로 Graph 객체의 as_graph_def() 메서드를 호출하면 된다.
con_func = simple_func.get_concrete_function()
print(con_func.graph.get_operations())
print(con_func.graph.as_graph_def())

### 결과
# [<tf.Operation 'a' type=Const>, <tf.Operation 'b' type=Const>, <tf.Operation 'c' type=Const>, <tf.Operation 'sub' type=Sub>, <tf.Operation 'mul/x' type=Const>, <tf.Operation 'mul' type=Mul>, <tf.Operation 'add' type=AddV2>, <tf.Operation 'Identity' type=Identity>]

# node {
# name: "a"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 1
# }
# }
# }
# }
# node {
# name: "b"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 2
# }
# }
# }
# }
# node {
# name: "c"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 3
# }
# }
# }
# }
# node {
# name: "sub"
# op: "Sub"
# input: "a"
# input: "b"
# attr {
# key: "T"
# value {
# type: DT_INT32
# }
# }
# }
# node {
# name: "mul/x"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 2
# }
# }
# }
# }
# node {
# name: "mul"
# op: "Mul"
# input: "mul/x"
# input: "sub"
# attr {
# key: "T"
# value {
# type: DT_INT32
# }
# }
# }
# node {
# name: "add"
# op: "AddV2"
# input: "mul"
# input: "c"
# attr {
# key: "T"
# value {
# type: DT_INT32
# }
# }
# }
# node {
# name: "Identity"
# op: "Identity"
# input: "add"
# attr {
# key: "T"
# value {
# type: DT_INT32
# }
# }
# }
# versions {
# producer: 119
# }
  • Identity 노드가 추가된 것 외에는 전체 구조가 동일하다.

텐서플로의 변수

  • 텐서플로에서 변수는 특별한 종류의 텐서 객체이다. 훈련 기간 동안 모델 파라미터를 저장하고 업데이트할 수 있다.
    • 예컨대 신경망의 입력층, 은닉층, 출력층에 있는 가중치이다.
    • 변수를 정의할 때 초기 텐서값을 지정해야 한다.
  • 텐서플로 변수를 정의하는 방법은 다음과 같다.
tf.Variable(<initial-value>, name=<optional-name>)
  • tf.Variable에는 shape이나 dtype을 설정할 수 없다. 크기와 타입은 초깃값과 동일하게 설정된다.
  • 텐서플로 1.x에서 변수를 만들고 공유하기 위해 사용했던 tf.get_variable() 함수는 2.x 버전에서는 삭제되었다.
    • 하위 버전과 호환성을 위해 tensorflow.compat.v1 아래로 이동되었다.
    • 텐서플로 1.x의 실제 변수 값은 파이썬 프로그램 객체가 아니라 그래프 노드로 존재한다. 파이썬 객체는 변수가 평가된 결과를 얻기 위한 참조 역할을 할 뿐이다.
    • 따라서 그래프에서 변수 노드를 공유하기 위해 변수 범위(variable scope)를 복잡하게 다루어야 했다.
    • 텐서플로 2.x에서는 보통 파이썬 객체를 공유하듯 변수를 공유할 수 있다.
  • 다음 코드에서 두 버전의 차이를 살펴보겠다. 우선 텐서플로 1.x 형식의 변수이다. 그래프 컨텍스트 블록 안에서 변수를 그래프에 추가했다.
import tensorflow as tf
import numpy as np

g1 = tf.Graph()

with g1.as_default():
    w1 = tf.Variable(np.array([[1,2,3,4], [5,6,7,8]]), name='w1')

print(w1)

### 결과
# <tf.Variable 'w1:0' shape=(2, 4) dtype=int32>
  • 변수 w1은 실제 값이 포함되어 있지 않다. g1 그래프에 포함된 연산을 출력해 보면 변수 초깃값(initial_value)과 할당 연산(Assign)이 각각 존재한다.
print(g1.get_operations())

### 결과
# [<tf.Operation 'w1/Initializer/initial_value' type=Const>, <tf.Operation 'w1' type=VarHandleOp>, <tf.Operation 'w1/IsInitialized/VarIsInitializedOp' type=VarIsInitializedOp>, <tf.Operation 'w1/Assign' type=AssignVariableOp>, <tf.Operation 'w1/Read/ReadVariableOp' type=ReadVariableOp>]
  • 이 변수는 사용하기 전에 초기화를 해주어야 한다. 변수 하나씩 초기화 연산을 실행해도 되지만 변수가 많을 때는 번거롭다.
    • 앞서 보았던 global_variables_initializer() 함수의 역할이 여기에 있다. 이 함수는 그래프에 있는 변수 초기화 연산을 한꺼번에 모아 준다.
with g1.as_default():
   init = tf.compat.v1.global_variables_initializer()
    print(init.node_def)

### 결과
# name: "init"
# op: "NoOp"
# input: "^w1/Assign"
  • 텐서플로 1.x 버전에서 변수를 다룰 때는 초기화 외에 주의해야 할 점이 하나 더 있다.
    • 다음과 같이 일반적인 파이썬 프로그래밍으로 코드를 작성하면 w1은 텐서플로 변수가 아니라 덧셈 연산을 가리키는 텐서로 바뀐다.
    • w1을 여러번 실행하더라도 변수 값이 증가하지 않고 원래 초깃값에 1을 더한 결과만 반복된다.
with g1.as_default():
    w1 = w1 + 1
    print(w1)

### 결과
# Tensor("add:0", shape=(2, 4), dtype=int32)


with tf.compat.v1.Session(graph=g1) as sess:
   init = tf.compat.v1.global_variables_initializer()
    sess.run(init)
   print(sess.run(w1))
    print(sess.run(w1))

### 결과
# [[2 3 4 5]
# [6 7 8 9]]
# [[2 3 4 5]
# [6 7 8 9]]
  • 이런 결과는 w1이 그래프에 있는 노드를 참조하는 역할이라고 생각하면 이해하기 쉽다.
    • 이를 해결하려면 텐서플로 1.x에서는 assign() 메서드를 사용하여 변수 값을 증가시키는 연산을 만들어야 한다.
    • 그 다음 변수를 초기화하고 변수 값을 증기사키는 연산을 실행해야 한다.
g2 = tf.Graph()

with g2.as_default():
    w1 = tf.Variable(np.array([[1,2,3,4], [5,6,7,8]]), name='w1')
    w1 = w1.assign(w1+1)

with tf.compat.v1.Session(graph=g2) as sess:
   init = tf.compat.v1.global_variables_initializer()
    sess.run(init)
   print(sess.run(w1))
    print(sess.run(w1))

### 결과
# [[2 3 4 5]
# [6 7 8 9]]
# [[ 3 4 5 6]
# [ 7 8 9 10]]
  • 텐서플로 2.x에서는 텐서플로 변수가 파이썬 객체 자체이므로 훨씬 다루기 쉽다. 동일한 초깃값을 가진 변수 w2를 정의해보자.
w2 = tf.Variable(np.array([[1, 2, 3, 4], [5, 6, 7, 8]]), name='w2')
print(w2)

### 결과
# <tf.Variable 'w2:0' shape=(2, 4) dtype=int32, numpy=
# array([[1, 2, 3, 4],
# [5, 6, 7, 8]])>
  • 텐서플로 1.x와 달리 2.x에서는 w2+1처럼 계산이 덧셈 연산의 출력 텐서가 아니라 덧셈이 적용된 상수 텐서를 반환한다.
print(w2 + 1)

### 결과
# tf.Tensor(
# [[2 3 4 5]
# [6 7 8 9]], shape=(2, 4), dtype=int32)
  • 따라서 텐서플로 2.x에서는 변수 값을 증가시키려면 이 덧셈 결과를 assign() 메서드로 반복해서 전달하면 된다. 호출 결과는 변수에 즉각 반영된다.
w2.assign(w2 + 1)
print(w2.numpy())

### 결과
# [[2 3 4 5]
# [6 7 8 9]]

w2.assign(w2 + 1)
print(w2.numpy())

### 결과
# [[ 3 4 5 6]
# [ 7 8 9 10]]
  • w2 변수를 다시 출력해 보면 numpy 속성이 바뀐 것을 볼 수 있다.
print(w2)

### 결과
# <tf.Variable 'w2:0' shape=(2, 4) dtype=int32, numpy=
# array([[ 3, 4, 5, 6],
# [ 7, 8, 9, 10]])>

tf.keras API 자세히 배우기

Sequential 모델

  • Sequential 모델은 층을 순서대로 쌓은 모델을 만든다. 13장에서 완전 연결 층인 Dense 층을 쌓은 모델을 만들어 보았는데, 15, 16장에서 Sequential 모델에 합성곱 층과 순환층을 적용해 보겠다.
  • 여기서는 간단한 회귀 분석을 위한 모델을 만든다. \hat{y} = wx + b 인 선형 회귀 모델을 구현하는 것이 목표이다.
    • 먼저 make_random_data 함수를 이용하여 하나의 특성을 가진 랜덤한 회귀용 데이터를 만들고 시각화 해 보자
import numpy as np
import matplotlib.pyplot as plt

np.random.seed(0)

def make_random_data():
   x = np.random.uniform(low=-2, high=2, size=200)
    y = []

   for t in x:
        r = np.random.normal(loc=0.0, scale=(0.5 + t*t/3), size=None)
       y.append(r)   

    return x, 1.726 * x - 0.84 + np.array(y)

x, y = make_random_data()

plt.plot(x, y, 'o')
plt.show()

  • 샘플 개수는 200개로 이중 150개를 훈련세트, 50개를 테스트 세트로 나누어 놓겠다.
    • 모델을 튜닝하기 위해서는 검증 세트가 필요한데, 친절하게도 tf.keras는 모델을 훈련할 때 간단한 설정만으로도 검증 세트의 점수를 자동으로 계산해 주기 때문에 훈련 세트와 테스트 세트로만 나눈다.
x_train, y_train = x[:150], y[:150]
x_test, y_test = x[150:], y[150:]
  • 예제 데이터가 준비되었으므로 모델을 만들어보겠다. 1차원 데이터셋이므로 출력 유닛 하나를 가진 간단한 완전 연결 층 하나를 추가하겠다.
model = tf.keras.Sequential()
model.add(tf.keras.layers.Dense(units=1, input_dim=1))
model.summary()

### 결과
# Model: "sequential"
# _________________________________________________________________
# Layer (type) Output Shape Param #
# =================================================================
# dense (Dense) (None, 1) 2
# =================================================================
# Total params: 2
# Trainable params: 2
# Non-trainable params: 0
  • 유닛이 하나뿐이므로 모델 파라미터 개수는 가중치와 절편 두 개 뿐이다. 이 모델을 컴파일하고 훈련 세트에서 학습시켜보자.
model.compile(optimizer='sgd', loss='mse')

history = model.fit(x_train, y_train, epochs=500, validation_split=0.3)

### 결과
# Train on 105 samples, validate on 45 samples
# Epoch 1/500
# 2020-05-02 10:03:27.367560: I tensorflow/stream_executor/platform/default/dso_loader.cc:44] Successfully opened dynamic library cublas64_100.dll
# 105/105 [==============================] - 1s 8ms/sample - loss: 5.2795 - val_loss: 3.1418
# Epoch 2/500
# 105/105 [==============================] - 0s 133us/sample - loss: 4.3258 - val_loss: 2.7091
# ...
# Epoch 500/500
# 105/105 [==============================] - 0s 114us/sample - loss: 0.7574 - val_loss: 0.9054
  • compile() 메서드의 loss 매개변수에 회귀 문제를 위한 평균 제곱 오차(mean squared error) 손실 함수 ‘mse’를 선택했다.
    • 회귀 문제 이외에도 평균 절댓값 오차(mean absolute error) 손실 함수인 ‘mae’를 지정할 수도 있다.
    • 13장에서 다중 클래스 분류 문제를 위한 범주형 크로스 엔트로피 ‘categorical_crossentropy’를 사용해 보았다. 이진 분류 문제에는 이진 크로스 엔트로피 ‘binary_crossentropy’를 선택한다.
  • optimizer 매개변수에는 경사 하강법 옵티마이저를 지정한다.
    • 13장에서는 SGD 클래스의 객체를 직접 만들어 전달했다. SGD 클래스의 기본값을 사용할 때 간편하게 ‘sgd’로 지정할 수 있다.
    • SGD 클래스의 기본값은 학습률 lr이 0.001이고 momentum과 decay는 0이다.
    • 이외에도 인기 있는 여러 종류의 옵티마이저를 선택할 수 있는데, RMSprop 알고리즘을 위한 ‘rmsprop’, Adagrad는 ‘adagrad’, Adam은 ‘adam’을 선택한다.
  • fit() 메서드에서 검증 세트의 크기를 30%로 설정했다.
    • 훈련 과정을 출력해주는 verbose 매개변수의 기본값은 1로 진행바와 함께 검증 점수를 출력해준다.
    • verbose 매개변수를 0으로 지정하면 아무것도 출력되지 않는다.
    • fit() 메서드에서 반환받은 History 객체의 history 딕셔너리에는 에포크마다 계산한 손실 함수 값이 저장되어 있다. 훈련 세트에 대한 손실 함수 값은 ‘loss’에 담겨 있다.
    • 이 값을 그래프로 그리면 다음과 같다.
epochs = np.arange(1, 500+1)

plt.plot(epochs, history.history['loss'], label='Training losss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.show()

함수형 API

  • Sequential 모델은 층을 순서대로 쌓은 네트워크를 만든다. 이따금 이보다 더 복잡한 모델을 만들어야 할 때가 있다. tf.keras의 함수형 API는 다양한 토폴로지(topology)의 네트워크를 만들 수 있다.
    • 함수형 API에서는 입력과 출력 사이에 원한느 층을 자유롭게 조합할 수 있다.
    • 예제를 만들면서 살펴보자
import tensorflow as tf
from tensorflow.keras import Model, Input

input = tf.keras.Input(shape=(1,))
output = tf.keras.layers.Dense(1)(input)
  • 먼저 튜플로 입력 크기를 지정하여 Input 클래스의 객체를 만든다. 그 다음 앞서 보았던 Dense 클래스를 마치 함수처럼 호출한다. 이는 클래스 객체를 함수처럼 호출할 수 있는 파이썬의 특수한 __call__() 메서드를 사용한 것이다.
    • 마지막 코드는 다음과 같이 쓸 수 있다.
# 이렇게 쓰거나
dense = tf.keras.layers.Dense(1)
output = dense(input)

# 이렇게 쓸 수 있다
dense = tf.keras.layers.Dense(1)
output = dense.__call__(input)
  • Dense 층의 객체(dense)를 네트워크의 다른 부분에 재사용하지 않는다면(즉, 이 층의 가중치를 공유하지 않는다면) 객체 생성과 동시에 호출하는 것이 편리하다.
  • 그 다음 Model 클래스에 입력과 출력을 전달하여 신경망 모델을 만든다.
model = tf.keras.Model(input, output)
model.summary()

### 결과
# Model: "model"
# _________________________________________________________________
# Layer (type) Output Shape Param #
# =================================================================
# input_1 (InputLayer) [(None, 1)] 0
# _________________________________________________________________
# dense (Dense) (None, 1) 2
# =================================================================
# Total params: 2
# Trainable params: 2
# Non-trainable params: 0
  • 이전과 동일하게 두 개의 모델 파라미터를 가진 Dense 층 하나로 이루어져 있다.
    • Sequential 모델에서는 없었던 InputLayer는 입력 데이터를 위한 층이다. 이 층은 학습되는 파라미터를 가지고 있지 않다.
    • 사실 Sequential 모델은 summary() 메서드 출력에서 나타나지는 않지만 자동으로 InputLayer를 추가해 준다. Sequential 모델의 model._layers 속성을 출력하여 모델에 들어 있는 층을 모두 확인해 보면 좋다.
  • 함수형 API로 모델을 만든 후 컴파일과 훈련하는 단계는 Sequential 모델과 완전히 동일하다. 이전과 동일한 매개변수로 선형 회귀 모델을 훈련시키고 그래프로 그려 보자
    • 이번에는 훈련 손실과 함께 검증 세트에 대한 손실도 같이 그래프로 출력해 보겠다. 검증 세트에 대한 손실은 ‘val_loss’ 로 확인할 수 있다.
model.compile(optimizer='sgd', loss='mse')
history = model.fit(x_train, y_train, epochs=500, validation_split=0.3)

epochs = np.arange(1, 500+1)

plt.plot(epochs, history.history['loss'], label='Training loss')
plt.plot(epochs, history.history['val_loss'], label='Validation loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.show()

tf.keras 모델의 저장과 복원

  • tf.keras API로 만든 모델을 저장하고 복원하는 것은 아주 쉽다. 우선 앞서 만든 모델 가중치를 save_weights() 메서드로 저장해 보자.
model.save_weights('simple_weights.h5')
  • 위 코드를 실행하면 현재 폴더에 ‘simple_weights.h5’ 파일을 생성하고 모든 층의 가중치를 저장한다.
    • save_weights 메서드는 기본적으로 텐서플로의 체크포인트(checkpoint) 포맷으로 가중치를 저장한다.
    • save_format 매개변수를 ‘h5’로 지정하여 HDF5 파일 포맷으로 저장할 수 있다. 이 메서드는 똑똑하게도 파일 이름의 확장자가 .h5이면 자동으로 HDF5 포맷으로 저장한다.
    • 저장된 가중치를 사용하려면 새로운 모델을 만들고 load_weights() 메서드를 사용하여 가중치를 로드할 수 있다.
model = tf.keras.Sequential()
model.add(tf.keras.layers.Dense(units=1, input_dim=1))
model.compile(optimizer='sgd', loss='mse')

model.load_weights('simple_weights.h5')
  • 이 코드는 모델을 훈련시키지 않고 이전에 학습한 가중치를 사용한다. 가중치가 올바르게 로드되었는지 확인하기 위해 테스트 세트에 대한 손실 점수를 계산해 보자. 테스트 세트를 평가하려면 evaluate() 메서드를 사용한다.
model.evaluate(x_test, y_test)
  • 가중치 외에 모델 전체를 저장하기 위해 tf.keras 모델은 save() 메서드를 사용하여 가중치와 네트워크 구조까지 HDF5 포맷으로 저장할 수 있다. 모델을 저장하는 코드는 다음과 같다.
model.save('simple_model.h5')
  • 저장된 모델을 다시 로드하려면 load_model() 함수를 사용하면 된다. 저장된 모델 구조를 그대로 사용하기 때문에 다시 층을 추가할 필요가 없다.
model = tf.keras.models.load_model('simple_model.h5')
  • 모델을 훈련하는 동안 ModelCheckpoint 콜백을 사용하여 최고의 성능을 내는 가중치를 저장할 수 있다.
    • ModelCheckpoint 콜백은 더 이상 성능이 개선되지 않을 때 훈련을 멈추게 하는 EarlyStopping 콜백과 함께 사용하는 경우가 많다.
  • EarlyStopping 콜백 클래스는 기본적으로 검증 손실을 모니터링 한다. patience 매개변수가 지정한 에포크 횟수 동안 모니터링 지표가 개선되지 않으면 훈련을 중지한다.
  • 다음 코드는 ModelCheckpoint 콜백을 사용하는 예이다. 검증 손실을 모니터링하면서 최상의 모델 가중치를 저장한다.
model = tf.keras.Sequential()
model.add(tf.keras.layers.Dense(units=1, input_dim=1))
model.compile(optimizer='sgd', loss='mse')

callback_list = [tf.keras.callbacks.ModelCheckpoint(filepath='my_model.h5', monitor='val_loss', save_best_only=True), tf.keras.callbacks.EarlyStopping(patience=5)]

history = model.fit(x_train, y_train, epochs=500, validation_split=0.2, callbacks=callback_list)
  • 손실 그래프를 그려 보면 검증 손실이 감소되지 않으므로 훈련이 일찍 멈춘 것을 확인할 수 있다.
epochs = np.arange(1, len(history.history['loss'])+1)

plt.plot(epochs, history.history['loss'], label='Training loss')
plt.plot(epochs, history.history['val_loss'], label='Validation loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.show()

  • 저장된 모델을 로드한 후 ModelCheckpoint 콜백에서 저장한 가중치를 load_weights() 메서드로 적재한다. 이렇게 하면 모델 구조와 함께 최상의 모델 가중치를 유지할 수 있다.
model = tf.keras.models.load_model('simple_model.h5')
model.load_weights('my_model.h5')
model.evaluate(x_test, y_test)
  • 여기까지 학습한 선형 모델을 그래프로 그려보자. 짙은 원은 훈련 세트이고, 밝은 원은 테스트 세트이다.
x_arr = np.arange(-2, 2, 0.1)
y_arr = model.predict(x_arr)

plt.figure()
plt.plot(x_train, y_train, 'bo')
plt.plot(x_test, y_test, 'bo', alpha=0.3)
plt.plot(x_arr, y_arr, '-r', lw=3)
plt.show()

계산 그래프 시각화

  • 텐서플로의 놀라운 기능 중 하나는 텐서보드(TensorBoard)이다. 텐서보드는 모델의 학습 과정 뿐만 아니라 계산 그래프도 시각화할 수 있는 모듈이다.
    • 계산 그래프를 시각화 하면 노드 사이 연결을 보고 의존성을 확인하고 필요할 때 모델을 디버깅 할 수 있다.
  • 케라스 API에서는 콜백 함수를 통해 텐서 보드를 사용한다.
    • (예제는 앞선 절에서 했던 것을 그대로 사용한다)
model = tf.keras.Sequential()
model.add(tf.keras.layers.Dense(units=1, input_dim=1, kernel_regularizer='l2'))

callback_list = [TensorBoard()]

model.compile(optimizer='sgd', loss='mse')

history = model.fit(x_train, y_train, epochs=500, validation_split=0.3, callbacks=callback_list)
  • TensorBoard 콜백은 기본적으로 현재 폴더 아래에 log 폴더를 만들고 그래프와 손실 값을 저장한다. 터미널에서 다음 명령을 실행해 보자.
tensorboard --logdir=logs/

[실행 결과]
TensorBoard 2.0.2 at http://localhost:6006/ (Press CTRL+C to quit)
  • 위 결과와 같이 URL 주소가 출력되는데, 이 주소를 브라우저 주소 표시줄에 붙여 넣으면 아래 그림과 같이 텐서보드에 접속할 수 있다.

  • 그래프 탭을 클릭하면 모델에 상응하는 그래프가 나타난다.

  • 큰 사각 상자는 서브 네트워크를 나타낸다. 서브 네트워크의 모든 컴포넌트는 이 사각 상자 안에 그룹지어 있고, 이 상자를 확장하여 상세 사항을 살펴 볼 수 있다.
    • 마우스로 상자를 더블 클릭하면 상자가 확장된다.
    • 아래 그림과 같이 dense 서브 네트워크의 상세 사항을 볼 수 있다.

  • 그래프를 살펴보면 dense 네트워크는 두 개의 가중치 켄서 kernel과 bias를 가지고 있다. kernel은 입력과 곱한(MatMul) 후에 bias와 더하는 (BiasAdd) 과정이 그래프에 잘 나타나 있다.

텐서보드 익숙하게 다루기

케라스의 층 그래프 그리기

  • 케라스는 층의 그래프를 그릴 수 있는 plot_model() 함수를 제공한다. 이 함수를 사용하려면 pydot 파이썬 패키지를 설치해야 한다.
    • 두 개의 은닉층을 가진 간단한 모델을 만들어 plot_model() 함수를 적용해 보겠다.
input = tf.keras.Input(shape=(784,))
hidden = tf.keras.layers.Dense(100)(input)
output = tf.keras.layers.Dense(10)(hidden)

model = tf.keras.Model(input, output)
  • plot_model() 함수를 호출한다. to_file 매개변수에는 그래프에 저장할 파일 이름을 지정한다.
tf.keras.utils.plot_model(model, to_file='model_1.png')
  • model_1.png 파일을 열어 보면 다음과 같다. 네모 상자 안에 층 이름과 클래스 이름을 보여준다.

  • plot_model 함수의 show_shapes 매개변수를 True로 지정하면 층 입력과 출력 크기를 함께 나타내 준다.

머신 러닝 교과서/ 텐서플로를 사용하여 신경망 훈련

고성능 머신 러닝 라이브러리 텐서플로

  • 텐서플로는 머신 러닝 작업 속도를 크게 높여준다.
    • 기본적으로 파이썬은 GIL(Global Interpreter Lock) 때문에 하나의 코어만 사용할 수 있다. 멀티프로세싱 라이브러릴 사용해서 여러 개의 코어에 연산을 분산할 수 있지만 최성능 데스크톱이라도 16개 이상의 코어를 가진 경우는 거의 없다.
    • 그러나 아주 간단한 다층 퍼셉트론이라도 하나의 은닉층이 100개의 유닛을 갖기 쉽기기 때문에, 단일 프로세스로는 수행하기가 어렵다.
    • 이 문제에 대한 해결책은 GPU를 사용하는 것이다.  최신 CPU 가격의 60% 정도로 코어 개수가 240배나 많고 초당 부동소수점 연산을 12배나 많이 할 수 있는 GPU를 구매할 수 있다.
    • 특정 GPU에 맞는 코드를 작성하는 일은 파이썬 인터프리터에서 코드를 실행하는 것처럼 간단하지 않다. CUDA나 OpenCL처럼 특정 GPU를 사용할 수 있도록 도와주는 패키지가 있지만 CUDA나 OpenCL에서 코드를 작성하는 것은 머신 러닝 알고리즘을 구현하고 실행하기에 편리한 환경이 아니다. 다행히 이런 이유 때문에 텐서플로가 개발되었다.

텐서플로란?

  • 텐서플로는 머신 러닝 알고리즘을 구현하고 실행하기 위한 프로그래밍 인터페이스로서 확장이 용이하고 다양한 플랫폼을 지원하며 딥러닝을 위한 간단한 인터페이스도 포함하고 있다.
    • 텐서플로는 구글 브레인 팀 연구자와 엔지니어들이 개발했는데, 초창기에는 구글 내부 용도로 개발되었지만 2015년 11월 오픈 소스 라이선스로 릴리스 되었다.
  • 머신 러닝 모델의 훈련 성능을 향상시키기 위해 텐서플로는 CPU와 GPU를 모두 활용할 수 있다. 그러나 GPU를 사용할 때 최대 성능을 이끌어낼 수 있다.
    • 텐서플로는 공식적으로 CUDA 기반의 GPU를 지원한다. OpenCL 기반의 GPU 지원은 아직 실험적이다.
    • 텐서플로는 여러 프로그래밍 언어를 지원하는데 파이썬 API는 완전히 성숙되어 있기 때문에 많은 머신 러닝, 딥러닝 기술자들에게 인기가 높다.
    • 텐서플로는 공식적인 C++ API도 가지고 있다. 자바, 하스켈, Node Js, Go 같은 언어를 위한 API는 아직 안정적이지 않다.
  • 텐서플로 연산은 데이터 흐름을 표현하는 유향 그래프(directed graph)를 구성하여 수행된다.

텐서플로 학습 방법

  • 먼저 텐서플로의 저수준 API를 배워보겠다. 이런 방식으로 모델을 만드는 것이 처음에는 조금 번거로울 수 있지만 프로그래머가 기본 연산을 연결하여 복잡한 모델을 개발할 수 있도록 자유도를 높여주는 장점이 있다.
    • 텐서플로 1.1.0 버전부터 저수준 API 위에 고수준 API가 추가되었다.
    • 텐서플로 1.4.0 버전부터는 케라스가 contrib 모듈에서 벗어나 핵심 모듈(tf.keras)이 되었고, 텐서플로 2.0에서는 tf.keras가 최상위 파이썬 API가 되었다. 이런 고수준 API를 사용하면 훨씬 빠르게 모델을 만들고 실험할 수 있다.
  • 우선 저수준 API를 배운 후에 고수준 API인 tf.keras를 살펴보겠다.

텐서플로 시작

  • 파이썬의 pip 인스톨러를 사용하여 PyPI로부터 텐서플로를 설치할 수 있다.
pip install tensorflow
  • GPU를 사용하려면 CUDA 툴킷과 NVIDIA cuDNN을 설치해야 한다. GPU용 텐서플로는 다음과 같이 설치할 수 있다.
pip install tensorflow-gpu
  • 텐서플로는 노드 집합으로 구성된 계산 그래프를 바탕으로 한다. 각 노드는 0개 이상의 입력이나 출력을 갖는 연산을 나타낸다. 계산 그래프의 에지를 따라 이동하는 값을 텐서(tensor)라고 한다.
  • 텐서는 스칼라(scalar), 벡터, 행렬이 일반화된 것으로 생각할 수 있다.
    • 구체적으로 말해 스칼라는 랭크 0 텐서이고, 백터는 랭크 1 텐서, 행렬은 랭크 2 텐서로 정의한다.
    • 행렬을 세 번째 차원으로 쌓아 올리면 랭크 3 텐서가 된다.
  • 텐서플로의 스칼라를 사용한 첫 번째 예제로 1차원 데이터셋 x 와 가중치 w , 절편 b 로부터 최종입력 z 를 계산해 보겠다.

z = w \times x + b

  • 아래 코드는 텐서플로 1.x 방식의 저수준 API를 사용하여 이 식을 구현한 것이다.
import tensorflow as tf

# 그래프를 생성한다
g = tf.Graph()

with g.as_default():
    x = tf.compat.v1.placeholder(dtype=tf.float32, shape=(None), name='x')
   w = tf.Variable(2.0, name='weight')
    b = tf.Variable(0.7, name='bias')
    z = w * x + b

    init = tf.compat.v1.global_variables_initializer()

# 세션을 만들고 그래프 g를 전달한다.
with tf.compat.v1.Session(graph=g) as sess:
    ## w와 b를 초기화한다.
    sess.run(init)

    ## z를 평가한다.
   for t in [1.0, 0.6, -1.8]:
        print('x=%4.1f --> z=%4.1f' % (t, sess.run(z, feed_dict={x:t})))
  • 보통 텐서플로 1.x 방식의 저수준 API로 개발할 때 입력 데이터(x, y 또는 튜닝이 필요한 다른 파라미터)를 위해 플레이스홀더(placeholder)를 정의한다.
    • 그 다음 가중치 행렬을 정의하고 입력에서부터 출력까지 연결된 모델을 만든다.
    • 최적화 문제인 경우에는 손실 함수 또는 비용 함수를 정의하고 어떤 최적화 알고리즘을 사용할지 결정한다.
    • 텐서플로의 그래프는 정의된 모든 요소를 노드로 포함시킨다.
  • 그 다음 세션을 만들고 변수를 초기화한다. 앞서 shape=(None) 으로 플레이스홀더 x를 만들었다. 입력 데이터 크기를 정의하지 않았으므로 다음과 같이 배치 데이터를 한 번에 전달하여 원소 하나씩 차례대로 모델에 주입할 수 있다.
with tf.compat.v1.Session(graph=g) as sess:
    sess.run(init)
    print(sess.run(z, feed_dict={x:[1., 2., 3.]}))
  • 이 코드에 있는 텐서 z를 출력해 보면 다음과 같다.
print(z)
# Tensor("add:0", dtype=float32)
  • 텐서 이름 “add:0″은 z가 덧셈 연산의 첫 번째 출력이라는 것을 알려준다.
    • 텐서 z에는 실제 어떤 값도 들어있지 않다. 세션을 열고 z를 평가해야 비로소 값을 얻을 수 있다.
    • 이렇게 텐서플로 1.x 방식에서는 그래프 정의 단계와 실행 단계로 크게 나누어져 있다.
    • z를 평가할 때 필요한 데이터가 있다면 feed_dict 매개변수를 통해 플레이스홀더에 데이터를 주입해야 한다.
  • 텐서플로 2.x 방식으로 위와 동일한 계산을 만들어 보겠다.
import tensorflow as tf

w = tf.Variable(2.0, name='weight')
b = tf.Variable(0.7, name='bias')

for x in [1.0, 0.6, -1.8]:
    z = w * x + b
    print('x=%4.1f --> z=%4.1f' % (x, z))
  • 훨씬 간단하게 수행할 수 있다. Session 객체를 만들어서 플레이스홀더에 데이터를 주입하는 대신 파이썬 리스트를 사용하여 직접 z 값을 계산했다.
    • 텐서플로 2.x 방식에서는 변수 초기화 과정도 불필요하다.
  • z 값을 출력하면 다음과 같다.
print(z)
tf.Tensor(-2.8999999, shape=(), dtype=float32)
  • 여기서 z는 덧셈 연산의 출력 텐서가 아니라 실제 값을 가진 텐서이다.
  • 또 다음과 같이 for 반복문을 사용하지 않고 리스트 데이터를 한 번에 계산할 수도 있다.
z = w * [1., 2., 3.] + b
print(z.numpy())
  • 위 코드처럼 텐서 값을 넘파이 배열로 출력하려면 numpy() 메서드를 사용하면 된다.

배열 구조 다루기

  • 텐서플로에서 배열 구조를 어떻게 다루는지 알아보자.
    • 다음 코드를 실행하면 3 x 2 x 3 크기의 간단한 랭크 3 텐서를 만든다. 그 다음 크기를 바꾸고 텐서플로의 최적화된 연산을 사용하여 각 열의 합을 계산한다.
import tensorflow as tf
import numpy as np

x_array = np.arange(18).reshape(3, 2, 3)
x2 = tf.reshape(x_array, shape=(-1, 6))

## 각 열의 합을 계산한다
xsum = tf.reduce_sum(x2, axis=0)

## 각 열의 평균을 계산한다
xmean = tf.reduce_mean(x2, axis=0)

print('입력 크기:', x_array.shape)
print('크기가 변경된 입력:\n', x2.numpy())
print('열의 합:\n', xsum.numpy())
print('열의 평균:\n', xmean.numpy())

# 결과
# 입력 크기: (3, 2, 3)
# 크기가 변경된 입력:
# [[ 0 1 2 3 4 5]
# [ 6 7 8 9 10 11]
# [12 13 14 15 16 17]]
# 열의 합:
# [18 21 24 27 30 33]
# 열의 평균:
# [ 6 7 8 9 10 11]
  • reshape 메서드의 첫 번째 차원으로 -1을 사용했는데, 텐서 크기를 바꿀 때 특정 차원에 -1을 사용하면 텐서 전체 크기와남은 차원에 따라 계산된다.
    • 예컨대 tf.reshape(tensor, shape=(-1,))은 일렬로 텐서를 펼친다.
  • tf.reduce_sum과 tf.reduce_mean은 각각 텐서의 합과 평균을 계산하는데, axis 매개변수는 축소될 차원을 지정한다.
    • axis=0은 행 차원을 축소시켜 각 열의 합이나 평균을 계산한다.
    • axis=1은 열 차원을 축소시켜 각 행의 합이나 평균을 계산한다.
    • axis 매개변수의 기본값은 None으로 모든 차원을 축소하여 스칼라 텐서를 반환한다.

텐서플로 저수준 API로 간단한 모델 개발

  • 텐서플로를 이용하여 최소 제곱법(Ordinary Least Squares, OLS) 회귀를 구현해보자
  • 먼저 10개의 훈련 샘플로 이루어진 작은 1차원 데이터셋을 만든다.
import tensorflow as tf
import numpy as np

X_train = np.arange(10).reshape((10, 1))
y_train = np.array([1.0, 1.3, 3.1, 2.0, 5.0, 6.3, 6.6, 7.4, 8.0, 9.0])
  • 이 데이터셋으로 입력 x 에서 출력 y 를 예측하는 선형 회귀 모델을 훈련하려고 한다. 이 모델을 TfLinreg라는 이름의 클래스로 구현해 보자.
    • 이 클래스에서 훈련되는 변수인 가중치 w 와 절편 b 를 정의하자.
    • z = w \times x + b 처럼 선형 회귀 모델을 정의한 후 평균 제곱 오차(Mean Squared Error, MSE)를 비용함수로 정의한다.
    • 모델 가중치를 학습하기 위해 경사 하강법 옵티마이저를 사용한다.
    • 전체 코드는 아래와 같다.
class TfLinreg(object):

   def __init__(self, learning_rate=0.01):
        ## 가중치와 절편을 정의한다
       self.w = tf.Variable(tf.zeros(shape=(1)))
        self.b = tf.Variable(tf.zeros(shape=(1)))

       ## 경사 하강법 옵티마지어를 설정한다
    self.optimizer = tf.keras.optimizers.SGD(lr=learning_rate)   

   def fit(self, X, y, num_epochs=10):
       ## 비용 함수의 값을 저장하기 위한 리스트를 정의한다
        training_costs = []

       for step in range(num_epochs):
           ## 자동 미분을 위해 연산 과정을 기록한다
            with tf.GradientTape() as tape:
               z_net = self.w * X + self.b
               z_net = tf.reshape(z_net, [-1])
               sqr_errors = tf.square(y - z_net)
               mean_cost = tf.reduce_mean(sqr_errors)           

           ## 비용함수에 대한 가중치의 그래디언트를 계산한다
            grads = tape.gradient(mean_cost, [self.w, self.b])

            ## 옵티마이저에 그래디언트를 반영한다
            self.optimizer.apply_gradients(zip(grads, [self.w, self.b]))

           ## 비용 함수의 값을 저장한다
           training_costs.append(mean_cost.numpy())       

        return training_costs
  • 저수준 API를 사용하여 신경망을 구현할 때 그리디언트를 가장 손쉽게 계산하는 방법은 tf.GradientTape() 컨텍스트(context)를 사용하는 것이다.
    • GradientTape()는 with 블록 안에 있는 텐서플로 변수를 자동으로 감시한다. 변수에 대한 그래디언트를 구하려면 GradientTape 객체의 gradient() 메서드를 사용한다.
    • 이 메서드의 첫 번째 매개변수는 미분 대상 텐서 또는 텐서 리스트고, 두 번째 매개변수는 그래디언트를 구하려는 변수의 리스트이다.
    • 똑똑하게도 텐서플로는 gradient(0 메서드가 호출된 후 사용한 자원을 자동으로 반납한다.
  • 그래디언트를 계산했다면 옵티마이저에 이를 전달하여 가중치를 업데이트해야 한다. 옵티마이저의 apply_gradient() 메서드는 그래디언트와 변수를 쌍으로 하는 튜플의 리스트를 입력받는다.
    • 위 코드에서는 파이썬의 zip 반복자를 사용하여 이를 간결하게 표현했다.
  • 모델을 정의했으니 매개변수 기본값으로 이 클래스의 인스턴스를 만들고 모델을 훈련해 보자.
lrmodel = TfLinreg()
training_costs = lrmodel.fit(X_train, y_train)
  •  열 번의 에포크 동안 계산된 훈련 비용을 그래프로 그려서 모델이 수렴하는지 확인해 보자
import matplotlib.pyplot as plt

plt.plot(range(1, len(training_costs) + 1), training_costs)
plt.tight_layout()
plt.xlabel('Epoch')
plt.ylabel('Training Cost')
plt.show()

  • 비용 함수 값을 보면 이 데이터셋에 잘 맞는 회귀 모델을 만든 것 같다. 입력 특성을 기반으로 예측을 만들기 위해 새로운 메서드를 추가해 보자. 이 메서드는 타깃 데이터 없이 특성 값만 사용한다.
def predict(self, X):
        return self.w * X + self.b
  • 이제 훈련 데이터에서 학습된 선형 회귀 곡선을 그려 보자
plt.scatter(X_train, y_train, marker='s', s=50, label='Training Data')
plt.plot(range(X_train.shape[0]), lrmodel.predict(X_train), color='gray', marker='o', markersize=6, linewidth=3, label='LinReg Model')
plt.xlabel('x')
plt.ylabel('y')
plt.legend()
plt.tight_layout()
plt.show()

tf.keras API로 다층 신경망 훈련

  • 케라스는 2015년 초에 개발되기 시작했고 현재 가장 인기 있고 널리 사용되는 딥러닝 라이브러리 중 하나이다. 케라스는 씨아노(Theano)와 텐서플로를 백엔드(backend)로 사용한다.
  • 텐서플로와 비슷하게 케라스는 GPU를 사용하여 신경망 훈련을 가속시킬 수 있다. 매우 직관적이고 사용하기 쉬운 API를 가진 것이 눈에 띄는 특징이다. 단 몇 줄의 코드만으로 신경망을 구현할 수 있다.
  • 케라스는 처음에 씨아노를 백엔드로 사용하는 독립된 API로 릴리스 되었고 나중에 추가로 텐서플로를 지원했다. 텐서플로 버전 1.1.0 부터는 케라스가 텐서플로에 통합되었다. 텐서플로 1.1.0 이상을 쓰고 있다면 케라스를 따로 설치할 필요가 없다.
  • 텐서플로 1.4.0 부터는 케라스가 contrib 서브모듈 밖으로 이동하여 텐서플로의 핵심 모듈(tf.keras)가 되었고, 텐서플로 2.0에서는 tf.keras가 표준 파이썬 API가 되었다.
  • 케라스를 사용하면 텐서플로를 사용하여 신경망 모델을 손쉽게 구현할 수 있다.

훈련 데이터 준비

  • tf.keras 고수준 API를 사용하여 어떻게 신경망을 훈련하는지 알아보자. 이를 위해 MNIST 데이터셋의 손글씨 숫자를 분류하는 다층 퍼셉트론을 만들어보자.
  • 파일을 내려받은 후 현재 작업 디렉터리 안에 mnist 폴더를 만들어 옮긴다. 그 다음 12장에서 구현한 load_mnist 함수를 사용하여 훈련 데이터셋과 테스트 데이터셋을 로드한다.
X_train, y_train = mn.load_mnist('mnist/', kind='train')
X_test, y_test = mn.load_mnist('mnist/', kind='t10k')

mean_vals = np.mean(X_train, axis=0)
std_val = np.std(X_train)

X_train_centered = (X_train - mean_vals) / std_val
X_test_centered = (X_test - mean_vals) / std_val

del X_train, X_test

print(X_train_centered.shape, y_train.shape)
print(X_test_centered.shape, y_test.shape)
  • 일관된 결과를 만들기 위해 넘파이 난수 초깃값을 설정하자
np.random.seed(123)
  • 훈련 데이터를 준비하기 위해 클래스 레이블(0-9 사이의 정수)을 원-핫 인코딩으로 변경하자. tf.keras에는 이를 위한 편리한 도구가 준비되어 있다.
y_train_onehot = tf.keras.utils.to_categorical(y_train)

print('처음 3개 레이블: ', y_train[:3])
print('\n처음 3개 레이블 (원-핫):\n ', y_train_onehot[:3])

### 결과
# 처음 3개 레이블: [5 0 4]
# 처음 3개 레이블 (원-핫):
# [[0. 0. 0. 0. 0. 1. 0. 0. 0. 0.]
# [1. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
# [0. 0. 0. 0. 1. 0. 0. 0. 0. 0.]]
  • to_categorical 함수는 입력 텐서에서 가장 큰 정수를 찾아 원-핫 인코딩 크기를 결정한다. 앞 예에서처럼 정수 0은 첫 번째 원소가 1이 되는 식이다.
    • num_classes 매개변수를 사용하여 입력의 최댓값보다 더 큰 원-핫 인코딩을 만들 수도 있다.

피드포워드 신경망 구성

  • 이제 신경망을 구현하겠다. 간단하게 세 개의 완전 연결된 층을 만들어 보자.
    • 처음 두 개의 층은 하이퍼볼릭 탄젠트(tanh) 활성화 함수를 가진 50개의 은닉 유닛으로 이루어진다.
    • 마지막 층은 열 개의 클래스 레이블에 해당하는 열 개의 은닉 유닛을 가진다.
    • 마지막 층은 각 클래스의 확률을 계산하기 위해 소프트맥스(softmax) 함수를 사용한다.
    • 다음 코드처럼 tf.keras는 이런 작업을 매우 간단하게 처리할 수 있다
model = tf.keras.models.Sequential()

model.add(tf.keras.layers.Dense(units=50, input_dim=X_train_centered.shape[1], kernel_initializer='glorot_uniform', bias_initializer='zeros', activation='tanh'))

model.add(tf.keras.layers.Dense(units=50, input_dim=50, kernel_initializer='glorot_uniform', bias_initializer='zeros', activation='tanh'))

model.add(tf.keras.layers.Dense(units=y_train_onehot.shape[1], input_dim=50, kernel_initializer='glorot_uniform', bias_initializer='zeros', activation='softmax'))
  • 먼저 Sequential 클래스를 사용하여 피드포워드 신경망을 구현하는 새로운 모델을 초기화한다. 그 다음 원하는 만큼 층을 추가할 수 있다.
    • 처음 추가한 층은 입력층과 연결되기 떄문에 input_dim 속성이 훈련 세트에 있는 특성(열) 개수와 일치해야 한다.
    • 또 두 개의 연속된 층에서 출력 유닛(units)과 입력 유닛(input_dim)이 일치해야 한다. 위 코드에서 두 개의 은닉층은 50개의 은닉 유닛과 한 개의 절편 유닛을 가진다.
    • 출력층의 유닛 개수는 고유한 클래스 레이블의 개수와 같아야 한다. 원-핫 인코딩된 클래스 레이블 배열의 열 개수이다.
  • 지금까지 만든 모델 구조를 summary() 메서드를 사용하여 출력하면 다음과 같다.
    • 출력의 시작 부분이 신경망 입력에 가까운 층이고 끝부분이 신경망 출력에 가까운 층이다.
model.summary()

#### 결과
# Model: "sequential"
# _________________________________________________________________
# Layer (type) Output Shape Param #
# =================================================================
# dense (Dense) (None, 50) 39250
# _________________________________________________________________
# dense_1 (Dense) (None, 50) 2550
# _________________________________________________________________
# dense_2 (Dense) (None, 10) 510
# =================================================================
# Total params: 42,310
# Trainable params: 42,310
# Non-trainable params: 0

피드포워드 신경망 훈련

  • 모델 구성을 마치면 훈련을 수행하기 전에 모델을 컴파일 해야 한다. 이 단계에서 최적화할 손실 함수를 정의하고 최적화에 사용할 경사 하강법 옵티마이저를 선택한다.
    • 이전 장에서 사용해 보았던 확률적 경사 하강법 최적화를 선택하겠다.
    • 또 에포크마다 학습률을 조절하기 위한 학습률 감쇠 상수와 모멘텀 값을 지정한다.
    • 마지막으로 비용 함수(또는 손실 함수)를 categorical_crossentropy로 설정한다.
  • 이진 크로스 엔트로피는 로지스틱 손실 함수의 기술적인 표현이다.
    • 범주형 크로스 엔트로피는 소프트맥스를 사용하여 다중 클래스 예측으로 일반화한 것이다. 이에 대해서는 차후에 설명하겠다.
    • 옵티마이저 설정과 모델을 컴파일하는 코드는 다음과 같다.
sgd_optimizer = tf.keras.optimizers.SGD(lr=0.001, decay=1e-7, momentum=.9)
model.compile(optimizer=sgd_optimizer, loss='categorical_crossentropy')
  • 모델을 컴파일한 후 fit 메서드를 호출하여 훈련시킨다.
    • 여기서는 미니 배치 경사하강법을 사용하겠다. 배치마다 담긴 훈련 샘플 개수는 64개다.
    • 50번의 에포크 동안 MLP를 훈련시키겠다.
    • verbose=1로 설정하여 훈련하는 동안 비용 함수의 최적화 과정을 따라가보겠다.
    • validation_split 매개변수는 아주 유용한데, 0.1로 설정하면 훈련 데이터의 10%를 검증 데이터로 따로 떼어낸다. 에포크마다 이 데이터로 검증 점수를 계산하므로 모델이 과대적합되었는지 모니터링 할 수 있다.
history = model.fit(X_train_centered, y_train_onehot, batch_size=64, epochs=50, verbose=1, validation_split=0.1)

### 결과
# Train on 54000 samples, validate on 6000 samples
# Epoch 1/50
# 2020-05-01 10:08:46.207749: I tensorflow/stream_executor/platform/default/dso_loader.cc:44] Successfully opened dynamic library cublas64_100.dll
# 54000/54000 [=========================] - 2s 43us/sample - loss: 0.6957 - val_loss: 0.3554
# Epoch 2/50
# 54000/54000 [=========================] - 1s 27us/sample - loss: 0.3662 - val_loss: 0.2747
# Epoch 3/50
# 54000/54000 [=========================] - 2s 28us/sample - loss: 0.3039 - val_loss: 0.2395
# ...
# Epoch 50/50
# 54000/54000 [=========================] - 2s 28us/sample - loss: 0.0479 - val_loss: 0.1110
  • 훈련하는 동안 비용 함수 값을 출력하는 기능은 아주 유용하다. 훈련 도중에 비용이 감소하는지 여부를 빨리 확인해서 감소하지 않는다면 하이퍼파라미터를 튜닝하기 위해 알고리즘을 일찍 멈출 수 있다.
  • 클래스 레이블을 예측하려면 predict_classes 메서드를 사용하여 정수로 된 클래스 레이블을 얻을 수 있다.
  • 클래스 레이블을 예측하려면 predict_classes 메서드를 사용하여 정수로된 클래스 레이블을 얻을 수 있다.
    • 마지막으로 훈련 세트와 테스트 세트에서 모델 정확도를 출력해 보자
y_train_pred = model.predict_classes(X_train_centered, verbose=0)
correct_preds = np.sum(y_train==y_train_pred, axis=0)
train_acc = correct_preds / y_train.shape[0]
print('처음 3개 예측: ', y_train_pred[:3])
print('훈련 정확도: %.2f%%' % (train_acc * 100))

### 결과
# 처음 3개 예측: [5 0 4]
# 훈련 정확도: 98.91%

y_test_pred = model.predict_classes(X_test_centered, verbose=0)
correct_preds = np.sum(y_test==y_test_pred, axis=0)
test_acc = correct_preds / y_test.shape[0]
print('처음 3개 예측: ', y_test_pred[:3])
print('훈련 정확도: %.2f%%' % (test_acc * 100))

### 결과
# 처음 3개 예측: [7 2 1]
# 훈련 정확도: 96.37%

다층 신경망의 활성화 함수 선택

  • 지금까지 다층 피드포워드 신경망을 쉽게 이해하기 위해 시그모이드 활성화 함수만 설명했다. 12장에서 다층 퍼셉트론을 구현할 때 출력층과 은닉층에 이 함수를 사용했다.
    • 이반적으로 다른 문헌에서 부르는 것처럼 이 활성화 함수를 시그모이드 함수라고 했는데, 좀 더 정확한 정의는 로지스틱(logistic) 함수이다.
  • 기술적으로는 미분 가능하다면 어떤 함수라도 다층 신경망의 활성화 함수로 사용할 수 있다.
    • 아달린에서처럼 선형 활성화 함수도 사용할 수 있지만, 실제로 은닉층이나 출력층에서 선형 활성화 함수를 사용하는 것이 그리 유용하지는 않다.
    • 복잡한 문제를 해결하기 위해서는 일반적인 인공 신경망에 비선형성이 필요하기 때문이다.
    • 선형 함수를 합치면 결국 하나의 선형 함수가 된다.
  • 12장에서 사용한 로지스틱 활성화 함수가 뉴런 개념을 가장 비슷하게 흉내 낸 함수이다. 이 함수 출력을 뉴런의 활성화 여부에 대한 확률로 생각할 수 있다.
  • 로지스틱 활성화 함수는 큰 음수 입력이 들어오면 문제가 된다.
    • 이 경우 시그모이드 함수의 출력이 0에 가까워지기 때문이다. 시그모이드 함수가 0에 가까운 출력을 내면 신경망이 매우 느리게 학습한다.
    • 또한 훈련 과정에서 지역 최솟값에 갇힐 가능성이 높다.
    • 이런 이유로 은닉층에 하이퍼볼릭 탄젠트 함수를 더 선호한다.

로지스틱 함수 요약

  • 종종 시그모이드 함수라고 불리는 로지스틱 함수는 시그모이드 함수의 특별한 경우이다.
    • 3장 로지스틱 회귀에 관한 절에서 로지스틱 함수를 사용하여 이진 분류 문제일 때 샘플 x 가 양성 클래스(클래스 1)에 속할 확률을 모델링 했다.
    • 최종 입력 z 는 다음 식으로 계산된다.

z = w_{0} x_{0} + w_{1} x_{1} + ... + w_{m} x_{m} = \sum_{i=0}^{m} w_{i} x_{i} = w^{T} x

  • 로지스틱 함수는 다음과 같이 계산한다.

\phi_{logistic}(z) = {1 \over 1 + e^{-z}}

  • w_{0} 는 절편 유닛이다 (y 축과의 교차점. 즉 x_{0} = 1 이다)
    • 구체적인 예를 들기 위해 2차원 데이터 포인트 x 와 다음과 같은 가중치 벡터 w 로 구성된 모델을 가정해 보자
import numpy as np

X = np.array([1, 1.4, 2.5])
w = np.array([0.4, 0.3, 0.5])

def net_input(X, w):
    return np.dot(X, w)

def logistic(z):
    return 1.0 / (1.0 + np.exp(-z))

def logistic_activation(X, w):
    z = net_input(X, w)
    return logistic(z)
  • 이 특성과 가중치 값을 사용하여 최종 입력을 계산하고 이것으로 로지스틱 뉴런의 활성화 출력을 구하면 0.888을 얻는다. 이 샘플 x가 양성 클래스에 속할 확률이 88.8%라고 해석할 수 있다.
  • 12장에서 여러 개의 로지스틱 활성화 유닛으로 구성된 출력층 값을 계산하기 위해 원-핫 인코딩 기법을 사용했다.
    • 다음 코드에서처럼 여러 개의 로지스틱 활성화 유닛으로 구성된 출력층은 의미 있게 해석할만한 확률 값을 만들지 못한다.
W = np.array([[1.1, 1.2, 0.8, 0.4], [0.2, 0.4, 1.0, 0.2], [0.6, 1.5, 1.2, 0.7]])
A = np.array([[1, 0.1, 0.4, 0.6]])
Z = np.dot(W, A[0])

y_probas = logistic(Z)

print('최종 입력: \n', Z)
print('출력 유닛: \n', y_probas)

### 결과
# 최종 입력:
# [1.78 0.76 1.65]
# 출력 유닛:
# [0.85569687 0.68135373 0.83889105]

  • 출력에서 볼 수 있듯이 클래스가 세 개일 때 결과 확률을 이해하기 어렵다. 그것은 세 개의 합이 1이 아니기 때문이다.
    • 사실 클래스 소속 확률을 구하는 것이 아니라 클래스 레이블을 예측하기 위해서만 사용한다면 큰 문제는 아니다. 앞의 출력 결과에서 클래스 레이블을 예측하는 방법은 가장 큰 값을 선택하는 것이다.
  • 어떤 경우에는 다중 클래스 예측 문제에서 의미 있는 클래스 확률을 계산할 필요가 있다. 다음 절에서는 이런 문제를 다루기 위해 로지스틱 함수를 일반화한 softmax 함수를 살펴보겠다.

소프트맥스 함수를 사용하여 다중 클래스 확률 예측

  • 이전 절에서 argmax 함수를 사용하여 클래스 레이블을 구현하는 방법을 보았는데, 사실 소프트맥스는 간접적인 argmax 함수이다.
    • 하나의 클래스 인덱스를 찾는 대신 각 클래스의 확률을 반환하므로 다중 클래스 환경(다중 로지스틱 회귀(multinomial logistic regression))에서 의미 있는 클래스 확률을 계산할 수 있다.
  • softmax 함수는 특정 샘플의 최종 입력이 z 일 때 i 번째 클래스에 속할 확률을 M 개의 선형 함수 합으로 나누어 정규화한 것이다.

P(y=i|z) = \phi (z) = {e^{Z_{i}} \over \sum_{j=1}^{M} e^{Z_{j}}}

  • softmax 함수의 동작을 확인해 보기 위해 직접 만들어 보자
def softmax(z):
    return np.exp(z) / np.sum(np.exp(z))

y_probas = softmax(Z)
print('확률:\n', y_probas)

# 결과
# 확률:
# [0.44668973 0.16107406 0.39223621]
  • 예측된 클래스 확률 합은 1이 되었다. 예측 클래스 레이블은 로지스틱 출력에 argmax 함수를 적용한 것과 같다. softmax 함수는 출력을 정규화하여 다중 클래스일 때 의미 있는 클래스 소속 확률을 만든다.

하이퍼볼릭 탄젠트로 출력 범위 넓히기

  • 인공 신경망의 은닉층에 많이 사용하는 또 다른 시그모이드 함수는 하이퍼볼릭 탄젠트(hyperbolic tangent) (보통 tanh 라고 함)이다. 이 함수는 스케일이 조정된 로지스틱 함수라고 생각할 수 있다.

\phi_{logistic}(z) = {1 \over 1 + e^{-z}}

\phi_{tanh}(z) = 2 \times \phi_{logistic} (2z) - 1 = {e^{z} - e^{-z} \over e^{z} + e^{-z}}

  • 로지스틱 함수에 비해 하이퍼볼릭 탄젠트 함수의 장점은 출력 범위를 (-1, 1) 사이로 넓혀서 역전파 알고리즘의 수렴을 향상시킬 수 있다는 것이다.
    • 로지스틱 함수는 (0, 1) 범위의 출력 신호를 반환한다.
  • 두 시그모이드 함수를 그래프로 그리면 아래와 같다.
def tanh(z):
   e_p = np.exp(z)
   e_m = np.exp(-z)
    return (e_p - e_m) / (e_p + e_m)

  • 두 시그모이드 곡선은 매우 비슷하지만 tanh 함수가 logistic 함수보다 2배 큰 범위 출력을 갖는다.
  • 위에서는 tanh 함수와 logistic 함수를 직접 구현했지만 실전에서는 넘파이의 tanh 함수와 사이파이의 special 모듈에서 두 함수를 사용할 수 있다.

렐루 활성화 함수

  • 렐루(Rectified Linear Unit, ReLU)는 심층 신경망에 자주 사용되는 또 다른 활성화 함수이다. 렐루를 알아보기 전에 하이퍼볼릭 탄젠트와 로지스틱 활성화 함수의 그래디언트 소실 문제(vanishing gradient problem)를 살펴보자.
  • 최종 입력이 z_{1} = 20 에서 z_{2} = 25 로 바뀐다고 가정하자. 하이퍼볼릭 탄젠트 활성화 함수를 계산하면 \phi (z_{1}) \approx 1.0 \phi (z_{2}) \approx 1.0 이므로 출력에 변화가 없다.
    • 이는 최종 입력에 대한 활성화 함수의 도함수가 w_{0} 가 커짐에 따라 줄어든다는 뜻이다.
    • 결국 그래디언트가 0에 아주 가까워지기 때문에 훈련 과정 동안 가중치가 매우 느리게 학습된다.
    • 렐루 활성화 문제는 이런 문제를 해결한다. 렐루 함수의 수학적 정의는 다음과 같다.

\phi (z) = \max(0, z)

  • 렐루도 신경망이 복잡한 함수를 학습하기에 좋은 비선형 함수이다. 입력 값이 양수면 입력에 대한 렐루의 도함수는 항상 1이다.
    • 이것이 그래디언트 소실 문제를 해결해 주므로 심층 신경망에 적합하다.
    • 다음 장에서는 다층 합성곱 신경망을 위한 활성화 함수로 렐루 함수를 사용해 보겠다.

머신 러닝 교과서/ 다층 인공 신경망을 밑바닥부터 구현

인공 신경망으로 복잡한 함수 모델링

  • 2장에서 다루었던 인공 뉴런은 다층 인공 신경망의 구성 요소이다. 인공 신경망 이면에 있는 기본 개념은 사람의 뇌가 어떻게 복잡한 문제를 푸는지에 대한 가설과 모델을 기반으로 한다.
    • 인공 신경망이 최근 몇 년간 인기를 끌고 있지만 신경망에 대한 초기 연구는 1940년대 워렌 맥컬록(Warren McCulloch)과 월터 피츠(Walter Pitts)가 처음 뉴런 작동 방식을 기술했던 때로 올라간다.
  • 맥컬록-피츠 뉴런 모델인 로젠블라트의 퍼셉트론이 1950년대 처음 구현된 이후 수시년 동안 많은 연구자와 머신러닝 기술자는 신경망에 대한 관심을 잃었는데, 다층 신경망을 훈련하기 위한 좋은 방법이 없었기 때문이다.
    • 마침내 루멜하트(D. E. Rumelhart), 힌튼(G. E. Hinton), 윌리엄스(R. J. Wiliams)가 1986년 신경망을 효과적으로 훈련시키는 역전파 알고리즘을 재발견하면서 신경망에 대한 관심이 다시 살아났다.
  • 신경망이 요즘처럼 인기를 끌었던 때는 없었는데, 딥러닝 알고리즘과 여러 개의 층으로 이루어진 신경망 구조는 지난 10여년 간 일어난 많은 혁신의 결과물이다.

단일층 신경망 요약

  • 다층 신경망 구조를 본격적으로 배우기 전에 단일층 신경망 네트워크 개념을 되새겨보자.
  • 이는 2장에서 소개한 아달린(ADAptive LInear NEuron, Adaline) 알고리즘으로 아래 그림과 같다.

  • 2장에서 이진 분류를 수행하는 아달린 알고리즘을 구현했는데, 경사 하강법 최적화 알고리즘을 사용하여 모델 가중치를 학습했다.
  • 훈련 세트를 한 번 순회하는 에포크마다 가중치 벡터 w 를 업데이트하기 위해 다음 공식을 사용한다.

w := w + \Delta w

\Delta w = - \eta \nabla J(w)

  • 다른 말로 하면 전체 훈련 세트에 대한 그래디언트를 계산하고 그래디언트 \nabla J(w) 의 반대 방향으로 진행하도록 모델 가중치를 업데이트 했다.
    • 최적의 모델 가중치를 찾기 위해 제곱 오차합(Sum of Squared Errors, SSE) 비용 함수 J(w) 로 정의된 목적 함수를 최적화 한다.
    • 또 학습률 \eta 를 그래디언트에 곱한다.
    • 학습률은 비용 함수의 전역 최솟값을 지나치지 않도록 학습 속도를 조절하기 위해 신중하게 선택해야 한다.
  • 경사 하강법 최적화에서는 에포크마다 모든 가중치를 동시에 업데이트 한다. 가중치 벡터 w 에 있는 각각의 가중치 w_{j} 에 대한 편도 함수는 다음과 같이 정의한다.

{\partial \over \partial w_{j}} J(w) = - \sum_{i} (y^{(i)} - a^{(i)}) x_{j}^{(i)}  

  • 여기서 y^{(i)} 는 특정 샘플 x^{(i)} 의 타깃 클래스 레이블이다.
    • a^{(i)} 는 뉴런의 활성화 출력이다.
    • 아달린은 선형 함수를 사용하므로 활성화 함수 \phi (\cdot) 는 다음과 같이 정의한다.

\phi (z) = z = a  

  • 여기서 최종 입력 z 는 입력층과 출력층을 연결하는 가중치의 선형 결합이다.

z = \sum_{j} w_{j} x_{j} = w^{T} x  

  • 업데이트할 그래디언트를 계산하기 위해 활성화 함수를 사용했지만, 예측을 위해 임계 함수를 구현하여 연속적인 출력 값을 이진 클래스 레이블로 압축했다.

\hat{y} = \begin{cases} 1 & g(z) \geq 0 \\ -1 & else \end{cases}

  • 또 모델의 학습을 가속시키기 위해 확률적 경사 하강법(stochastic gradient descent) 최적화 기법도 배웠다.
    • 확률적 경사 하강법은 하나의 훈련 샘플(온라인 학습) 또는 적은 수의 훈련 샘플(미니 배치 학습)을 사용해서 비용을 근사한다.
    • 차후에 이 장에서 다층 퍼셉트론을 구현하고 훈련 시킬 때 이 개념을 사용하겠다. 경사 하강법에 비해 더 자주 가중치를 업데이트하기 때문에 학습이 빠르다.
    • 이에 더해 들쭉날쭉한 학습 특성이 비선형 활성화 함수를 사용한 다층 신경망을 훈련 시킬 때 장점이 될 수 있다. 이런 신경망의 비용 함수는 하나의 볼록 함수가 아니기 때문이다.
    • 확률적 경사하강법에서 생기는 잡음은 지역 최솟값을 탈출하는데 도움이 된다.

다층 신경망 구조

  • 이 절에서는 여러 개의 단일 뉴런을 연결하여 다층 피드포워드(feedforward) 신경망을 만드는 방법을 배워보겠다. 완전 연결 네트워크의 특별한 경우로 다층 퍼셉트론(Multilayer Perceptron, MLP)라고도 한다.
  • 다음 그림은 세 개의 층으로 구성된 MLP 개념을 나타낸다.

  • 위 그림에 나타난 MLP는 입력층 하나, 은닉층 하나, 출력층 하나를 가진다.
    • 은닉층의 유닛은 입력층과 완전 연결되어 있고, 출력층은 은닉층과 완전 연결되어 있다.
    • 하나 이상의 은닉층을 가진 네트워크를 심층 인공 신경망(deep artificial nerual network)라고 한다.
  • 위 그림과 같이 l 번째 층에 있는 i 번째 유닛의 활성화 출력을 a_{i}^{(l)} 이라고 하겠다.
    • 수식과 코드를 간단하게 만들기 위해 층을 나타내는 인덱스는 사용하지 않겠다. 그 대신 입력층에 대해서는 in 위첨자를 사용하고 은닉층은 h 위첨자, 출력층은 out 위 첨자를 사용하겠다.
    • 예컨대 a_{i}^{(in)} 는 입력층의 i 번째 유닛이고 a_{i}^{(h)} 는 은닉층의 i 번째 유닛이고 a_{i}^{(out)} 는 출력층의 i 번째 유닛을 나타낸다.
    • 여기서 활성화 출력 z z 는 절편을 위해 추가한 특성으로 1이 된다.
    • 입력층의 유닛 활성화는 입력 값에 절편을 더한 것과 같다.

a^{(in)} = \left[ \begin{array}{rrrr} a_{0}^{(in)} \\ a_{1}^{(in)} \\ ... \\ a_{m}^{(in)} \end{array} \right] = \left[ \begin{array}{rrrr} 1 \\ x_{1}^{(in)} \\ ... \\ x_{m}^{(in)} \end{array} \right]

  • l 에 있는 각 유닛이 층 l + 1 에 있는 모든 유닛과 연결되어 있다. 예컨대 층 l 에 있는 k 번째 유닛과 층 l + 1 에 있는 j 번째 유닛 사이의 연결은 w_{k, j}^{(l+1)} 이라고 쓴다.
  • 위 그림을 다시 보면 입력층과 은닉층을 연결하는 가중치 행렬을 W^{(h)} 로 표시할 수 있다.
    • 은닉층과 출력층을 연결하는 가중치 행렬은 W^{(out)} 으로 나타낼 수 있다.
  • 이진 분류 작업에서는 출력층의 유닛이 하나여도 충분하지만 위 그림은 OvA(One-versus-All) 기법을 적용하여 다중 분류를 수행할 수 있는 일반적인 신경망 형태이다.
  • 입력층과 은닉층을 연결하는 가중치를 행렬 W^{(h)} \in \mathbb{R}^{m \times d} 로 나타낸다.
    • 여기서 d 는 은닉 유닛의 개수고, m 는 절편을 포함한 입력 유닛의 개수이다.

정방향 계산으로 신경망 활성화 출력 계산

  • MLP 모델의 출력을 계산하는 정방향 계산(forward propagation) 과정을 설명해 보겠다. MLP 모델 학습과 어떻게 관련되는지 이해하기 위해 세 단계로 MLP 학습 과정을 요약해 보자.
    1. 입력층에서 시작해서 정방향으로 훈련 데이터의 패턴을 네트워크에 전파하여 출력을 만든다.
    2. 네트워크 출력을 기반으로 비용 함수를 이용하여 최소화해야 할 오차를 계산한다.
    3. 네트워크에 있는 모든 가중치에 대한 도함수를 찾아 오차를 역전파하고 모델을 업데이트 한다.
  • 이 세 단계를 여러 에포크 동안 반복하고 MLP 가중치를 학습한다. 그런 다음 클래스 레이블을 예측하기 위해 정방향 계산으로 네트워크 출력을 만들고 임계 함수를 적용한다.
    • 이 클래스 레이블은 앞서 설명했던 원-핫 인코딩으로 표현된다.
  • 이제 훈련 데이터에 있는 패턴으로부터 출력을 만들기 위해 정방향 계산 과정을 따라가보자.
    • 은닉층에 있는 모든 유닛은 입력층에 있는 모든 유닛과 연결되어 있기 때문에 다음과 같이 은닉층 a_{1}^{(h)} 의 활성화 출력을 계산한다.

z_{1}^{(h)} = a_{0}^{(in)} w_{0, 1}^{(h)} + a_{1}^{(in)} w_{1, 1}^{(h)} + ... + a_{m}^{(in)} w_{m, 1}^{(h)}

a_{1}^{(h)} = \phi (z_{1}^{(h)})

  • 여기서 z_{1}^{(h)} 는 최종 입력이고 \phi(\cdot) 는 활성화 함수이다. 이 함수는 그래디언트 기반 방식을 사용하여 뉴런과 연결된 가중치를 학습하기 위해 미분 가능해야 한다.
  • 이미지 분류 같은 복잡한 문제를 해결하기 위해 MLP 모델에 비선형 활성화 함수를 사용해야 한다. 예컨대 시그모이드(로지스틱) 활성화 함수가 있다.

\phi (z) = {1 \over 1 + e^{-z}}

  • 기억을 떠올려 보면 시그모이드 함수는 S자 모양의 그래프로 최종 입력 z 를 0과 1사이 로지스틱 분포로 매핑한다. 이 그래프는 아래 그림과 같이 z = 0 에서 y축을 지난다.

  • MLP는 대표적인 피드포워드 인공 신경망의 하나이다. 피드 포워드(feed forward)란 용어는 각 층에서 입력을 순환시키지 않고 다음 층으로 전달한다는 의미이다. 이는 순환 신경망과 다르다.
    • MLP 네트워크에서 사용한 인공 뉴런이 퍼셉트론이 아니고 시그모이드이기 때문에 다층 퍼셉트론이란 용어가 혼동될 수 있다.
    • 간단히 말해 MLP 뉴런을 0과 1사이의 연속적인 값을 반환하는 로지스틱 회귀 유닛이라 생각할 수 있다.
  • 효율적이고 읽기 쉽게 기초적인 선형대수를 사용하여 활성화 출력을 좀 더 간단히 써보자.
    • 이렇게 하면 for 반복문을 중복하여 사용하지 않고 넘파이를 사용하여 벡터화된 구현을 만들 수 있다.

z^{(h)} = a^{(in)} W^{(h)}

a^{(h)} = \phi (z^{(h)})

  • 여기서 a^{(in)} 는 샘플 x^{(in)} 에 절편을 더한 1 \times m 차원 특성 벡터이다.
    • W^{(h)} m \times d 차원의 가중치 행렬이다.
    • d 는 은닉층의 유닛 개수이다.
  • 행렬-벡터 곱셉을 하면 1 \times d 차원의 최종 입력 벡터 z^{(h)} 를 얻어 활성화 출력 a^{(h)} 를 계산할 수 있다. (여기서 a^{(h)} \in \mathbb{R}^{1 \times d} )
    • 또 훈련 세트에 있는 모든 n 개의 샘플에 이 계산을 일반화 시킬 수 있다.

Z^{(h)} = A^{(in)} W^{(h)}

  • 여기서 A^{(in)} n \times m 행렬이다.
    • 행렬-행렬 곱셉을 하면 n \times d 차원의 최종 입력 행렬 Z^{(h)} 가 얻어진다.
  • 마지막으로 최종 입력 행렬의 각 값에 활성화 함수 \phi (\cdot) 를 적용하여 다음 층에 전달할 n \times d 차원의 활성화 행렬 A^{(h)} 를 얻는다.

A(h) = \phi(Z(h))

  • 비슷하게 출력층의 활성화도 여러 샘플에 대한 벡터 표현으로 쓸 수 있다.

Z^{(out)} = A^{(h)} W^{(out)}

  • 여기서 n \times d 차원 행렬 A^{(h)} d \times t 차원 (t 는 출력 뉴런 개수) 행렬 W^{(out)} 를 곱해 n \times t 차원 행렬 Z^{(out)} (이 행렬의 열은 각 샘플 출력 값)을 얻는다.
  • 마지막으로 시그모이드 활성화 함수를 적용하여 실수로 된 네트워크 출력을 얻는다.

A^{(out)} = \phi (Z^{(out)}), A^{(out)} \in \mathbb{R}^{n \times t}

손글씨 숫자 분류

MNIST 데이터셋 구하기

  • MNIST 데이터셋은 미국 NIST에서 만든 두 개의 데이터셋으로 구성되어 있다.
    • 훈련 세트는 250명의 사람이 쓴 솔글씨 숫자인데, 50%는 고등학생이고 50%는 인구 조사국직원이다.
  • MNIST 데이터셋은 http://yann.lecun.com/exdb/mnist/에 공개되어 있으며 다음 네 부분으로 구성되어 있다.
    • 훈련 세트 이미지: train-images-idx3-ubyte.gz
    • 훈련 세트 레이블: train-labels-idx1-ubyte.gz
    • 테스트 세트 이미지: t10k-images-idx3-ubyte.gz
    • 테스트 세트 레이블: t10k-labels-idx1-ubyte.gz
  • 내려 받은 MNIST 데이터셋을 불러오는 코드는 다음과 같다.
import os
import struct
import numpy as np

def load_mnist(path, kind='train'):
   """'path'에서 MNIST 데이터 불러오기"""
   labels_path = os.path.join(path, '%s-labels.idx1-ubyte' % kind)
   images_path = os.path.join(path, '%s-images.idx3-ubyte' % kind)

   with open(labels_path, 'rb') as lbpath:
       magic, n = struct.unpack('>II', lbpath.read(8))
       labels = np.fromfile(lbpath, dtype=np.uint8)   

    with open(images_path, 'rb') as imgpath:
        magic, num, rows, cols = struct.unpack(">IIII", imgpath.read(16))
       images = np.fromfile(imgpath, dtype=np.uint8).reshape(len(labels), 784)
       images = ((images/255.) - .5) & 2   

    return images, labels
  • 위 코드는 두 개의 배열을 반환하는데, 첫 번째는 n \times m 차원의 넘파이 배열(images)이다.
    • 여기서 n 은 샘플 개수이고 m 은 특성(여기서는 픽셀) 개수이다.
    • 두번째 배열(labels)는 이미지에 해당하는 타깃 값을 갖고 있다. 이 값은 손글씨 숫자의 클래스 레이블 (0-9까지의 정수)이다.
  • 코드의 마지막 부분에서 MNIST 픽셀 값을 -1에서 1 사이로 정규화하는데, 이는 그래디언트 기반의 최적화가 이런 조건하에서 훨씬 안정적이기 때문이다.
  • 다음 코드를 이용하면 데이터를 불러와서 결과를 확인할 수 있다.
import matplotlib.pyplot as plt

X_train, y_train = load_mnist('', kind='train')
print('Train 행: %d, 열: %d' % (X_train.shape[0], X_train.shape[1]))

X_test, y_test = load_mnist('', kind='t10k')
print('Test 행: %d, 열: %d' % (X_test.shape[0], X_test.shape[1]))

fig, ax = plt.subplots(nrows=2, ncols=5, sharex=True, sharey=True)
ax = ax.flatten()

for i in range(10):
   img = X_train[y_train==i][0].reshape(28, 28)
    ax[i].imshow(img, cmap='Greys')

ax[0].set_xticks([])
ax[0].set_yticks([])

plt.tight_layout()
plt.show()

  • (7번만 출력하는 예 생략)
  • 이전 단계를 모두 진행한 후 스케일된 이미지를 새로운 파이썬 세션에서 빠르게 읽을 수 있는 포맷으로 저장하는 것이 좋다. 이렇게 하면 데이터를 읽고 전처리하는 오버헤드를 피할 수 있다.
    • 넘파이 배열을 사용할 때 다차원 배열을 디스크에 저장하는 효율적이고 가장 간편한 방법은 넘파이 savez 함수이다. (아래 코드에서는 savez_compressed)
  • 다음 코드를 이용하면 훈련 세트와 테스트 세트를 파일로 저장하고 다시 읽을 수 있다.
import numpy as np
np.savez_compressed('mnist_scaled.npz', X_train=X_train, y_train=y_train, X_test=X_test, y_test=y_test)

minst = np.load('mnist_scaled.npz')
  • savez_compressed 함수에 전달한 키워드 매개변수를 이용하여 mnist 객체에 있는 네 개의 데이터 배열을 참조할 수 있다.
# savez_compressed 할 때 전달했던 키워드로 배열에 접근
X_train = mnist['X_train']
  • 다음 코드 처럼 리스트 내포 구문을 사용하면 네 개의 데이터 배열을 변수에 할당할 수 있다.
X_train, y_train, X_test, y_test = [mnist[f] for f in mnist.files]

다층 퍼셉트론 구현

  • 이제 입력층, 은닉층, 출력층이 각각 하나씩 있는 MLP 구현을 작성하고 MNIST 데이터셋 이미지를 분류해 보겠다.
  • 상세한 설명은 뒤에서 하고 우선 다음 코드를 작성하자
import numpy as np
import sys

class NeuralNetMLP(object):
    """ 피드포워드 신경망 / 다층 퍼셉트론 분류기

    매개변수
   -----------------
   n_hidden : int (기본값: 30)
      은닉 유닛 개수
   l2 : float (기본값: 0)
      L2 규제의 람다 값
     l2=0이면 규제 없음
   epochs : int (기본값: 100)
      훈련 세트를 반복할 횟수
   eta : float (기본값: 0.001)
      학습률
   shuffle : bool (기본값: True)
     에포크마다 훈련 세트를 섞을지 여부
     True면 데이터를 섞어 순서를 바꾼다
    minibatch_size : int (기본값: 1)
      미니 배치의 훈련 샘플 개수
    seed : int (기본값: None)
     가중치와 데이터 셔플링을 위한 난수 초깃값   

    속성
   ------------------
    eval_ : dict
      훈련 에포크마다 비용, 훈련 정확도, 검증 정확도를 수집하기 위한 딕셔너리
    """

   def __init__(self, n_hidden=30, l2=0., epochs=100, eta=0.001, shuffle=True, minibatch_size=1, seed=None):
       self.random = np.random.RandomState(seed)
        self.n_hidden = n_hidden
        self.l2 = l2
       self.epochs = epochs
        self.eta = eta
       self.shuffle = shuffle
       self.minibatch_size = minibatch_size   

    def _onehot(self, y, n_classes):
       """ 레이블을 원-핫 방식으로 인코딩

        매개변수
       ---------------
       y : 배열, 크기 = [n_samples]
         타깃 값       

        반환값
       ----------------
       onehot : 배열, 크기 = (n_samples, n_labels)
        """

        onehot = np.zeros((n_classes, y.shape[0]))

        for idx, val in enumerate(y.astype(int)):
           onehot[val, idx] = 1.       

       return onehot.T   

   def _sigmoid(self, z):
       """ 로지스킥 함수(시그모이드)를 계산 """
       return 1. / (1. + np.exp(-np.clip(z, -250, 250)))   

    def _forward(self, X):
       """ 정방향 계산을 수행"""

       # 단계 1: 은닉층의 최종 입력
        # [n_samples, n_features] dot [n_features, n_hidden] -> [n_samples, n_hidden]
        z_h = np.dot(X, self.w_h) + self.b_h

       # 단계 2: 은닉층의 활성화 출력
        a_h = self._sigmoid(z_h)

       # 단계 3: 출력층의 최종 입력
        # [n_samples, n_hidden] dot [n_hidden, n_classlabels] -> [n_samples, n_classlabels]
        z_out = np.dot(a_h, self.w_out) + self.b_out

       # 단계 4: 출력층의 활성화 출력
        a_out = self._sigmoid(z_out)

       return z_h, a_h, z_out, a_out   

   def _compute_cost(self, y_enc, output):
        """ 비용 함수를 계산

        매개변수
        -------------------
       y_enc : 배열, 크기 = (n_samples, n_labels)
         원-핫 인코딩된 클래스 레이블
       output : 배열, 크기 = [n_samples, n_output_units]
         출력층의 활성화 출력 (정방향 계산)       

        반환값
       ---------------
        cost : float
         규제가 포함된 비용
        """

        L2_term = (self.l2 * (np.sum(self.w_h ** 2.) + np.sum(self.w_out ** 2.)))
       term1 = -y_enc * (np.log(output))
       term2 = (1. - y_enc) * np.log(1. - output)
        cost = np.sum(term1 - term2) + L2_term

       return cost   

   def predict(self, X):
        """ 클래스 레이블을 예측

        매개변수
       ------------
       X : 배열, 크기 = [n_samples, n_features]
         원본 특성의 입력층       

        반환값
       ------------
       y_pred : 배열, 크기 = [n_samples]
          예측된 클래스 레이블
        """

       z_h, a_h, z_out, a_out = self._forward(X)
        y_pred = np.argmax(z_out, axis=1)

       return y_pred   

   def fit(self, X_train, y_train, X_valid, y_valid):
        """ 훈련 데이터에서 가중치를 학습합니다.

        매개변수
       --------------
       X_train : 배열, 크기 = [n_samples, n_features]
          원본 특성의 입력층
       y_train : 배열, 크기 = [n_samples]
          타깃 클래스 레이블
       X_valid : 배열, 크기 = [n_samples, n_features]
          훈련하는 동안 검증에 사용할 샘플 특성
        y_vaild : 배열, 크기 = [n_samples]
          훈련하는 동안 검증에 사용할 샘플 레이블

        반환값
       ------------
        self

        """

       n_output = np.unique(y_train).shape[0] # 클래스 레이블 개수
        n_features = X_train.shape[1]

       ##########################
        # 가중치 초기화
        ##########################

       # 입력층 -> 은닉층 사이의 가중치
       self.b_h = np.zeros(self.n_hidden)
        self.w_h = self.random.normal(loc=0.0, scale=0.1, size=(n_features, self.n_hidden))

       # 은닉층 -> 출력층 사이의 가중치
       self.b_out = np.zeros(n_output)
        self.w_out = self.random.normal(loc=0.0, scale=0.1, size=(self.n_hidden, n_output))
       epoch_strlen = len(str(self.epochs)) # 출력 포맷을 위해
        self.eval_ = {'cost':[], 'train_acc':[], 'valid_acc':[]}
        y_train_enc = self._onehot(y_train, n_output)

        # 훈련 에포크를 반복한다.
        for i in range(self.epochs):

            # 미니 배치로 반복한다.
            indices = np.arange(X_train.shape[0])

            if self.shuffle:
               self.random.shuffle(indices)           

            for start_idx in range(0, indices.shape[0] - self.minibatch_size + 1, self.minibatch_size):
                batch_idx = indices[start_idx: start_idx + self.minibatch_size]

               # 정방향 계산
                z_h, a_h, z_out, a_out = self._forward(X_train[batch_idx])

               ########################
                # 역전파
                ########################

               # [n_samples, n_classlabels]
                sigma_out = a_out - y_train_enc[batch_idx]

                # [n_samples, n_hidden]
                sigmoid_derivative_h = a_h * (1. - a_h)

                # [n_samples, n_classlabels] dot [n_classlabels, n_hidden] -> [n_samples, n_hidden]
                sigma_h = (np.dot(sigma_out, self.w_out.T) * sigmoid_derivative_h)

                # [n_features, n_samples] dot [n_samples, n_hidden] -> [n_features, n_hidden]
                grad_w_h = np.dot(X_train[batch_idx].T, sigma_h)
                grad_b_h = np.sum(sigma_h, axis=0)

               # [n_hidden, n_samples] dot [n_samples, n_classlabels] -> [n_hidden, n_classlabels]
               grad_w_out = np.dot(a_h.T, sigma_out)
                grad_b_out = np.sum(sigma_out, axis=0)

                # 규제와 가중치 업데이트
               delta_w_h = (grad_w_h + self.l2 * self.w_h)
               delta_b_h = grad_b_h # 편향은 규제하지 않는다.
                self.w_h -= self.eta * delta_w_h
                self.b_h -= self.eta * delta_b_h
                delta_w_out = (grad_w_out + self.l2 * self.w_out)
               delta_b_out = grad_b_out # 편향은 규제하지 않는다.
               self.w_out -= self.eta * delta_w_out
               self.b_out -= self.eta * delta_b_out           

            ####################
            # 평가
            ####################

           # 훈련하는 동안 에포크마다 평가한다.
           z_h, a_h, z_out, a_out = self._forward(X_train)
            cost = self._compute_cost(y_enc = y_train_enc, output=a_out)
           y_train_pred = self.predict(X_train)
            y_valid_pred = self.predict(X_valid)

            train_acc = ((np.sum(y_train == y_train_pred)).astype(np.float) / X_train.shape[0])
            valid_acc = ((np.sum(y_valid == y_valid_pred)).astype(np.float) / X_valid.shape[0])

            sys.stderr.write('\r%0*d/%d | 비용: %.2f | 훈련/검증 정확도: %.2f%%/%.2f%% ' % (epoch_strlen, i+1, self.epochs, cost, train_acc*100, valid_acc*100))
            sys.stderr.flush()

           self.eval_['cost'].append(cost)
           self.eval_['train_acc'].append(train_acc)
           self.eval_['valid_acc'].append(valid_acc)       

       return self
  • 위의 코드는 다음과 같이 사용할 수 있다.
nn = NeuralNetMLP(n_hidden=100, l2=0.01, epochs=200, eta=0.0005, minibatch_size=100, shuffle=True, seed=1)
  • 각 매개변수는 다음과 같다.
    • l2: 과대적합을 중리기 위한 L2 규제의 \lambda 파라미터
    • epochs: 훈련 세트를 반복할 횟수
    • eta: 학습률 \eta
    • shuffle: 알고리즘이 순환 고리에 갇히지 않도록 에포크를 싲가하기 전에 훈련 세트를 섞을지 여부
    • seed: 셔플과 가중치 초기화를 위한 난수 초깃값
    • minibatch_size: 확률적 경사 하강법에서 에포크마다 훈련 세트를 나눈 미니 배치에 들어갈 훈련 샘플 개수. 전체 훈련 세트에서 그래디언트를 계산하지 않고 학습 속도를 높이기 위해 미니 배치마다 계산한다.
  • 그 다음 이 MLP를 뒤섞어 놓은 55,000개의 MNIST 훈련 세트와 검증 용도인 5,000개의 샘플로 훈련시킨다.
    • 이 신경망을 훈련하는데 표준적인 컴퓨터 사양에서 약 5분정도 소요된다.
  • fit 메서드는 훈련 이미지, 훈련 레이블, 검증 이미지, 검증 레이블에 해당하는 네 개의 매개변수를 받고록 구현되어 있다.
    • 신경망 훈련에서는 훈련 정확도와 검증 정확도를 비교하는 것이 아주 중요하다. 신경망 모델이 주어진 구조와 하이퍼파라미터에서 잘 동작하는지 판단하는데 도움이 된다.
  • 일반적으로 (심층) 신경망을 훈련하는 것은 지금까지 배운 다른 모델에 비해 비교적 비용이 많이 든다.
    • 따라서 어떤 조건이 되면 일찍 중지하고 다른 하이퍼파라미터 설정을 시험하는 것이 좋다.
    • 또 훈련 데이터에 점차 과대적합되는 경향을 발견했다면 역시 훈련을 일찍 멈추는 것이 좋다.
  • 다음 코드를 이용하여 훈련을 할 수 있다.
nn.fit(X_train=X_train[:55000], y_train=y_train[:55000], X_valid=X_train[55000:], y_valid=y_train[55000:])
# 200/200 | 비용: 5065.78 | 훈련/검증 정확도: 99.28%/97.98%
  • NeuralNetMPL 구현에서 eval_ 속성을 정의하여 에포크마다 비용, 훈련 정확도, 검증 정확도를 수집했다. 이 결과를 그래프로 그려보자.

import matplotlib.pyplot as plt

plt.plot(range(nn.epochs), nn.eval_['cost'])
plt.ylabel('Cost')
plt.xlabel('Epochs')
plt.show()
  • 그림에서 볼 수 있듯 비용은 100번의 에포크 동안 많이 감소하고  그 이후는 천천히 수렴한다.
    • 하지만 175번째와 200번째 에포크 사이에 약간 경사가 있어서 에포크를 추가하여 훈련하면 비용은 더 감소할 것이다.
  • 훈련 정확도와 검증 정확도를 살펴보자

plt.plot(range(nn.epochs), nn.eval_['train_acc'], label='training')
plt.plot(range(nn.epochs), nn.eval_['valid_acc'], label='validation', linestyle='--')
plt.ylabel('Accuracy')
plt.xlabel('Epochs')
plt.legend()
plt.show()
  • 위 그래프는 네트워크 훈련 에포크가 늘어날수록 훈련 정확도와 검증 정확도 사이 간격이 증가한다는 것을 보여준다.
    • 약 50번째 에포크에서 훈련 정확도와 검증 정확도 값이 동일하고 그 이후에 네트워크는 훈련 세트에 과대적합되기 시작한다.
  • 과대적합 영향을 줄이는 한 가지 방법은 규제 강도를 높이는 것이다. 예컨대 l2를 0.1로 설정한다.
    • 신경망에서 과대적합을 해결하기 위해 사용하는 다른 방법은 15장에서 다룰 드롭아웃(dropout)이다.
  • 마지막으로 테스트 세트에서 예측 정확도를 계산하여 모델 일반화 성능을 평가해 보자.
y_test_pred = nn.predict(X_test)
acc = (np.sum(y_test == y_test_pred).astype(np.float) / X_test.shape[0])
print('테스트 정확도: %.2f%%' % (acc * 100))
# 테스트 정확도: 97.54%
  • 훈련 세트에 조금 과대적합되었지만 하나의 은닉층을 가진 간단한 이 신경망은 비교적 테스트 세트에서 좋은 성능을 달성했다.
    • 이 모델을 더 세밀하게 튜닝하면 은닉 유닛 개수나 규제 매개변수의 값, 학습률을 바꿀 수 있다. 최근 수년간 개발된 여러 다양한 기법이 있지만 이는 이 책의 범위를 넘어선다.
  • 끝으로 이 MLP 구현이 샘플 이미지를 어떻게 판단하는지 알아보자
miscl_img = X_test[y_test != y_test_pred][:25]
correct_lab = y_test[y_test != y_test_pred][:25]
miscl_lab = y_test_pred[y_test != y_test_pred][:25]

fit, ax = plt.subplots(nrows=5, ncols=5, sharex=True, sharey=True)
ax = ax.flatten()

for i in range(25):
   img = miscl_img[i].reshape(28, 28)
   ax[i].imshow(img, cmap='Greys', interpolation='nearest')
    ax[i].set_title('%d) t: %d p: %d' % (i+1, correct_lab[i], miscl_lab[i]))

ax[0].set_xticks([])
ax[0].set_yticks([])

plt.tight_layout()
plt.show()

  • 5×5 격자 그래프를 볼 수 있다. 각 그래프 제목에 나타난 첫 번째 숫자는 그래프 번호이고 두 번째 숫자는 클래스 레이블, 세 번째 숫자는 예측된 클래스 레이블을 나타낸다.

인경 신경망 훈련

로지스틱 비용 함수 계산

  • _compute_cost 메서드에 구현한 로지스틱 비용 함수는 3장에서 설명한 비용 함수와 같다.

J(w) = - \sum_{i=1}^{n} y^{[i]} \log (a^{[i]}) + (1 - y^{[i]}) \log (1 - a^{[i]})

  • 여기서 a^{[i]} 는 데이터셋 n 번째 샘플의 시그모이드 활성화 출력이다. 다음과 같이 정방향 계산을 통해서 구한다.

a^{[i]} = \phi (z^{[i]})

  • 여기서 위첨자 [i] 는 층이 아니라 훈련 샘플의 인덱스이다.
  • 과대적합의 정도를 줄여 주는 규제 항을 추가해보자. 앞서 배운대로 L2 규제 항은 다음과 같이 정의 된다.

L2 = \lambda \| w \|_{2}^{2} = \lambda \sum_{j = 1}^{m} w_{j}^{2}

  • L2 규제 항을 로지스틱 비용 함수에 추가하면 다음 식을 얻는다.

J(w) = - [\sum_{i=1}^{n} y^{[i]} \log (a^{[i]}) + (1 - y^{[i]}) \log (1 - a^{[i]})] + {\lambda \over 2} \|w\|_{2}^{2}

  • t 개의 원소를 가진 출력 벡터를 반환하는 다중 분류 MLP를 구현했다. 원-핫 인코딩 표현의 t \times 1 차원의 타깃 벡터와 비교해야 한다.
    • 예컨대 특정 샘플에 대한 세 번째 층의 활성화 출력과 타깃 클래스(여기서는 클래스 2)는 다음과 같다.

a^{(out)} = \left[ \begin{array}{rrrr} 0.1 \\ 0.9 \\ ... \\ 0.3 \end{array} \right], y = \left[ \begin{array}{rrrr} 0 \\ 1 \\ ... \\ 0 \end{array} \right]

  • 따라서 네트워크에 있는 t 개의 활성화 유닛 전체에 대해 로지스틱 비용 함수를 일반화 해야 한다. 결국 비용 함수는 다음과 같다. (규제 항 제외)

J(w) = - \sum_{i=1}^{n} \sum_{j=1}^{t} y_{j}^{[i]} \log (a_{j}^{[i]}) + (1 - y_{j}^{[i]}) \log (1 - a_{j}^{[i]})

  • 다음 일반화된 규제항은 l 층의 모든 가중치(절편 제외) 합을 더해 첫 번째 항에 추가한 것 뿐이다.

J(w) = - [\sum_{i=1}^{n} \sum_{j=1}^{t} y_{j}^{[i]} \log (a_{j}^{[i]}) + (1 - y_{j}^{[i]}) \log (1 - a_{j}^{[i]})] + {\lambda \over 2} \sum_{l=1}^{L-1} \sum_{i=1}^{u_{l}} \sum_{j=1}^{u_{l+1}} (w_{j, i}^{(l)})^{2}

  • 여기서 u_{l} l 층에 있는 유닛 개수를 나타낸다.
  • 결국 다음 식이 페널티 항을 나타낸다.

{\lambda \over 2} \sum_{l=1}^{L-1} \sum_{i=1}^{u_{l}} \sum_{j=1}^{u_{l+1}} (w_{j, i}^{(l)})^{2}

  • 비용 함수 J(W) 를 최소화하는 것이 목적이므로 네트워크의 모든 가중치에 대해 파라미터 W 의 편도 함수를 계산해야 한다.

{\partial \over \partial w_{j, i}^{(l)}} J(W)

  • 다음 절에서 비용 함수를 최소화하기 위한 편도 함수를 계산해 주는 역전파 알고리즘을 이야기해 보겠다.
  • W 는 여러 행렬로 구성되어 있다. 하나의 은닉층을 가진 다층 퍼셉트론에서는 W^{(h)} 가 입력층과 은닉층을 연결하는 가중치 행렬이고, W^{(out)} 이 은닉층과 출력층을 연결하는 가중치 행렬이다.
  • 3차원 텐서 W 를 이해하기 쉬운 그림으로 보면 다음과 같다.

  • 위 그림은 간단한 예로 MLP의 은닉 유닛, 출력 유닛, 입력 특성의 개수가 같지 않다면 W^{(h)} W^{(out)} 은 같은 행과 열을 갖고 있지 않다.

역전파 알고리즘 이해

  • 역전파 알고리즘이 재발견되어 널리 알려진지 30년이 넘었지만 효과적인 인공 신경망 훈련을 위해 가장 많이 사용되는 알고리즘 중 하나로 남아 있다.
  • 이 절에서는 간단하고 직관적으로 요약해 보겠다. 수학적으로 상세히 알아보기 전에 이 알고리즘의 큰 그림을 그려보겠다.
    • 핵심적으로 말해서 역전파 알고리즘은 다층 신경망에서 복잡한 비용 함수의 편미분을 효율적으로 계산하기 위한 방법으로 생각할 수 있다. 이 편미분을 사용하여 다층 인공 신경망의 가중치 파라미터를 학습한다.
    • 신경망은 전형적으로 고차원 특성 공간에서 비롯된 대규모 가중치를 다루어야 하기 때문에 학습하기 어렵다. 아달린이나 로지스틱 회귀처럼 단일층 신경망의 비용 함수와 달리 일반적인 신경망의 비용 함수 곡면은 볼록 함수가 아니거나 파라미터에 대해 매끄럽지 않다. 고차원 비용 함수의 곡면에는 전역 최솟값을 찾기 위해 넘어야 할 굴곡(지역 최솟값)이 많다.
  • 수학 시간에 배운 연쇄 법칙을 기억해 보자. 연쇄 법칙은 f(g(x)) 처럼 복잡하고 중첩된 함수의 도함수를 계산하는 방법이다. 예컨대 다음과 같다.

{d \over dx} [f(g(x))] = {df \over dg} \cdot {dg \over dx}

  • 비슷하게 임의의 긴 합성 함수에 연쇄 법칙을 사용할 수 있다. 예컨대 다섯 개의 다른 함수 f(x), g(x), h(x), u(x), v(x) 가 있다고 가정하자. F 는 함성함수로 F(x) = f(g(h(u(v(x))))) 이다.
    • 연쇄 법칙을 적용하면 다음과 같이 이 함수의 도함수를 계산할 수 있다.

{dF \over dx} = {d \over dx} F(x) = {d \over dx} f(g(h(u(v(x))))) = {df \over dg} \cdot {dg \over dh} \cdot {dh \over du} \cdot {du \over dv} \cdot {dv \over dx}

  • 컴퓨터 대수학(computer algebra)에서는 이런 문제를 효율적으로 풀기 위한 여러 기법을 개발했다. 이를 자동 미분(automatic differenctiation)
    • 자동 미분은 정방향과 역방향 두 가지 모드가 있다. 역전파는 역방향 자동 미분의 특별한 경우이다.
    • 핵심은 정방향 모드로 연쇄 법칙을 적용하면 계산 비용이 많이 들 수 있다는 것이다. 각 층마다 큰 행렬(야코비 행렬)을 곱한 후 마지막에 벡터를 곱해 출력을 얻기 때문이다.
    • 역방향 모드는 오른쪽에서 왼쪽으로 진행한다. 행렬과 벡터를 곱하여 또 다른 벡터를 얻은 후 다음 행렬을 곱하는 식이다.
    • 행렬-벡터 곱셉은 행렬-행렬 곱셈보다 훨씬 계산 비용이 적게 든다. 신경망을 훈련할 때 역전파 알고리즘이 가장 인기 있는 알고리즘이 된 이유이다.

역전파 알고리즘으로 신경망 훈련

  • 이전 절에서 마지막 층의 활성화 출력과 타깃 클래스 레이블 사이 차이인 비용을 계산하는 방법을 보았다. 이제 수학적 측면에서 역전파 알고리즘이 MLP 가중치를 업데이트하는 방법을 알아보자.
    • 이 부분은 fit 메서드의 ‘# 역전파’ 주석 아래에 구현되어 있다.
  • 먼저 출력층 활성화를 얻기 위해 정방향 계산을 수행해야 한다. 공식은 다음과 같다.
    • 은닉층의 최종 입력
      • Z^{(h)} = A^{(in)} W^{(h)}
    • 은닉층의 활성화 출력
      • A^{(h)} = \phi (Z^{(h)})
    • 출력층의 최종 입력
      • Z^{(out)} = A^{(h)} W^{(out)}
    • 출력층의 활성화
      • A^{(out)} = \phi (Z^{(out)})
  • 간단히 표현해서 아래 그림에 나타난 것처럼 입력 특성을 연결된 네트워크를 통해 앞으로 전파하는 것이다.

  • 역전파에서는 오차를 오른쪽에서 왼쪽으로 전파시킨다. 먼저 출력층의 오차 벡터를 계산한다.

\delta^{(out)} = a^{(out)} - y

  • 여기서 y 는 정답 클래스 레이블 벡터이다 (NeutralNetMLP 코드에서 출력층의 오차 벡터에 대응하는 변수는 sigma_out이다)
  • 다음으로 은닉층의 오차 항을 구해보자

\delta^{(h)} = \delta^{(out)} (W^{(out)})^{T} \odot {\partial \phi (z^{(h)}) \over \partial z^{(h)}}

  • \odot 는 원소별 곱셈을 의미한다.
  • 여기서 {\partial \phi (z^{(h)}) \over \partial z^{(h)}} 는 시그모이드 활성화 함수의 도함수이다.
    • NeuralNetMLP의 fit 메서드에서 sigmoid_derivative_h = a_h * (1. – a_h)로 계산한다.

{\partial \phi (z) \over \partial z} = (a^{(h)} \odot (1 - a^{(h)}))

  • 이제 은닉층의 오차행렬 \delta^{(h)} (sigma_h)는 다음과 같이 계산한다.

\delta^{(h)} = \delta^{(out)} (W^{(out)})^{T} \odot (a^{(h)} \odot (1 - a^{(h)}))

  • \delta^{(h)} 항을 어떻게 계산하는지 이해하기 위해 좀 더 자세히 살펴보자.
    • 앞 공식에서 h \times t 차원의 행렬 W^{(out)} 의 전치행렬 (W^{(out)})^{T} 를 사용했다. 여기서 h 는 은닉 유닛 개수고 t 는 출력 클래스 레이블 개수이다.
    • n \times t 차원 행렬 \delta^{(out)} t \times h 차원 행렬 (W^{(out)})^{T} 의 행렬 곱셉은 n \times h 차원의 행렬을 만든다.
    • 그 다음 동일 차원의 시그모이드 도함수를 원소별 곱셈하여 n \times h 차원 행렬 \delta^{(h)} 를 구한다.
    • 결국 \delta 를 구한 후에는 비용 함수의 도함수를 다음과 같이 쓸 수 있다.

{\partial \over \partial w_{i, j}^{(out)}} J(W) = a_{j}^{(h)} \delta_{i}^{(out)}

{\partial \over \partial w_{i, j}^{(h)}} J(W) = a_{j}^{(in)} \delta_{i}^{(h)}

  • 그 다음 각 층에 있는 모든 노드의 편도 함수와 다음 층의 노드 오차를 모아야 한다. 미니 배치에 있는 모든 샘플에 대해 \Delta_{i, j}^{(l)} 를 계산해야 한다는 것을 기억하자.
    • NeuralNetMLP 코드에 있는 것처럼 벡터화된 구현을 만든는 것이 좋다.

\Delta^{(h)} = (A^{(in)})^{T} \delta^{(h)}

\Delta^{(out)} = (A^{(h)})^{T} \delta^{(out)}

  • 편도 함수를 누적한 후 규제항을 추가한다.
    • 절편은 제외한다.

\Delta^{(l)} := \Delta^{(l)} + \lambda^{(l)} W

  • 앞의 두 수학 공식에 대응되는 NeuralNetMLP 코드의 변수는 delta_w_h, delta_b_h, delta_w_out, delta_b_out 이다.
  • 마지막으로 그래디언트를 계산하고 각 l 층에 대한 그래디언트의 반대 방향으로 가중치를 업데이트 한다.

W^{(l)} := W^{(l)} - \eta \Delta^{(l)}

  • 코드 구현은 다음과 같다.
self.w_h -= self.eta * delta_w_h
self.b_h -= self.eta * delta_b_h
self.w_out -= self.eta * delta_w_out
self.b_out -= self.eta * delta_b_out
  • 역전파 알고리즘 전체 과정을 그림으로 정리하면 다음과 같다.

신경망의 수렴

  • 손글씨 숫자 분류를 위해 신경망을 훈련시킬 때 기본 경사 하강법을 사용하지 않고 미니 배치 방식을 사용했는지 궁금할 것이다.
    • 온라인 학습에서는 한 번에 하나의 훈련 샘플(k = 1 )에 대해 그래디언트를 계산하여 가중치를 업데이트했다. 확률적이지만 매우 정확한 솔루션을 만들고 기본 경사 하강법보다 훨씬 빠르게 수렴한다.
    • 미니 배치 학습은 확률적 경사 하강법의 특별한 경우이다. n 개의 훈련 샘플 중 k 개의 부분 집합에서 그래디엍르르 계산한다. 1 < k < n
    • 미니 배치 학습은 벡터화된 구현을 만들어 계산 효율성을 높일 수 있다는 것이 온라인 학습보다 장점이다. 기본 경사 하강법보다 훨씬 빠르게 가중치가 업데이트 된다.
    • 직관적으로 보았을 때 미니 배치 학습을 대통령 선거의 투표율을 예측하기 위해 (실제 선거와 동일하게) 전체 인구가 아니라 일부 표본 집단에 설문하는 것으로 생각할 수 있다.
  • 다층 신경망은 아달린, 로지스틱 회귀, 서포트 벡터 머신 같은 알고리즘보다 훨씬 훈련하기 어렵다. 다층 신경망은 일반적으로 최적화해야 할 가중치가 수백 개, 수천 개, 심지어 수백만 개가 있다.
    • 게다가 아래 그림과 같이 손실 함수의 표면은 거칠어서 최적화 알고리즘이 쉽게 지역 최솟값에 갇힐 수 있다.

  • 신경망은 매우 많은 차원을 갖고 있어서 비용 함수의 곡면을 시각적으로 나타낼 수 없다.
    • 여기서는 하나의 가중치에 대한 비용 함수의 곡선을 x축에 타나냈다.
  • 이 그림은 알고리즘이 지역 최솟값에 갇혀서는 안된다는 것을 설명한다. 학습률을 크게 하면 지역 최솟값을 손쉽게 탈출 할 수 있지만, 거꾸로 전역 최솟값을 지나칠 수 있는 가능성도 높아진다.
    • 랜덤하게 가중치를 초기화하기 때문에 일반적으로 최적화 문제의 해는 잘못된 지점에서 출발하는 셈이다.

머신 러닝 교과서/ 레이블되지 않은 데이터 다루기: 군집 분석

k-평균 알고리즘을 사용하여 유사한 객체 그룹핑

  • 이 절에서는 가장 잘 알려준 군집(clustering) 알고리즘 중 하나인 k-평균(k-means)을 다루겠다. k-평균은 산업 현장은 물론 학계에서도 널리 사용되는 방법으로 비슷한 객체로 이루어진 그룹을 찾는 기법이다.

사이킷런을 사용한 k-평균 군집

  • k-평균 알고리즘은 구현하기 쉽고 다른 군집 알고리즘에 비해 계산 효율성이 높기 때문에 인기가 많다.
    • k-평균 알고리즘은 프로토타입 기반 군집(prototype-based clustering)에 속한다.
  • 프로토타입 기반 군집은 각 클러스터가 하나의 프로토타입으로 표현된다는 뜻이다.
    • 프로토타입은 연속적인 특성에서는 비슷한 데이터 포인트의 센트로이드(centroid, 평균)이거나 범주형 특성에서는 메도이드(medoid, 가장 대표되는 포인트나 가장 자주 등장하는 포인트)가 된다.
    • k-평균 알고리즘이 원형 클러스터를 구분하는데 뛰어나지만, 이 알고리즘의 단점은 사전에 클러스터 개수 k를 지정해야 한다는 것이다. 적절하지 않은 k를 고르면 군집 성능이 좋지 않다.
  • 다음 코드는 k-평균 군집 알고리즘에 사용할 간단한 2차원 데이터셋 예제를 만드는 것이다.
from sklearn.datasets import make_blobs
import matplotlib.pyplot as plt

X, y = make_blobs(n_samples=150, n_features=2, centers=3, cluster_std=0.5, shuffle=True, random_state=0)

plt.scatter(X[:,0], X[:,1], c='white', marker='o', edgecolor='black', s=50)
plt.grid()
plt.tight_layout()
plt.show()

  • 실제 군집 애플리케이션에서는 샘플에 대한 진짜 카테고리 정보가 전혀 없다. (그렇지 않으면 지도 학습에 해당한다) 여기서 목표는 특성의 유사도에 기초하여 샘플을 그룹으로 모으는 것이다.
  • 이런 문제에 사용할 수 있는 k-평균 알고리즘은 다음 네 단계로 요약된다.
    1. 샘플 포인트에서 랜덤하게 k개의 센트로이드를 초기 클러스터 중심으로 선택한다.
    2. 각 샘플을 가장 가까운 센트로이드 \mu^{(j)}, j \in \{ l, ..., k \} 에 할당한다.
    3. 할당된 샘플들의 중심으로 센트로이드를 이동한다.
    4. 클러스터 할당이 변하지 않거나 사용자가 지정한 허용 오차나 최대 반복 횟수에 도달할 때까지 2-3 단계를 반복한다.
  • 각 샘플간의 유사도는 거리의 반대 개념으로 정의할 수 있다.
    • 연속적인 특성을 가진 샘플을 클러스터로 묶는데 널리 사용되는 거리는 m-차원 공간에 있는 두 포인트 x, y 사이의 유클리디안 거리의 제곱(squared Euclidean distance)이다

d(x, y)^{2} = \sum_{j=1}^{m} (x_{j} - y_{j})^{2} = \| x- y \|_{2}^{2}

  • 위 식에서 인덱스 j 는 샘플 포인트 x, y j 번째 차원(특성 열)을 나타낸다.
  • 유클리디안 거리 지표를 기반으로 간단한 최적화 문제로 k-평균 알고리즘을 기술할 수 있다. 클러스터 내 제곱 오차합(SSE) 또는 클러스터 관성(cluster inertia)를 반복적으로 최소화하는 방법이다.

SSE = \sum_{i=1}^{n} \sum_{j=1}^{k} w^{(i, j)} \| x^{(i)} - \mu^{(j)} \|_{2}^{2}

  • 여기서 \mu^{(j)} 는 클러스터 j 의 대표 포인트(센트로이드)이다. 샘플 x^{(i)} 가 클러스터 j 안에 있다면 w^{(i, j)} = 1 이고 아니면 w^{(i, j)} = 0 이다.
  • 준비된 샘플 데이터셋에 사이킷런 cluster 모듈의 KMeans 클래스를 적용해 보자.
from sklearn.cluster import KMeans

km = KMeans(n_clusters=3, init='random', n_init=10, max_iter=300, tol=1e-04, random_state=0)

y_km = km.fit_predict(X)
  • 이 코드에서 클러스터 개수를 3으로 지정했다. 클러스터를 사전에 지정해야 하는 것은 k-평균 알고리즘의 한계 중 하나이다.
    • n_init = 10으로 설정하면 k-평균 군집 알고리즘을 각기 다른 랜덤한 센트로이드에서 독립적으로 열 번 실행하여 가장 낮은 SSE를 만드는 하나를 최종 모델로 선택한다.
    • max_iter 매개변수는 한 번의 실행에서 수행할 최대 반복 횟수를 지정한다. 사이킷런의 k-평균 구현은 최대 반복 횟수에 도달하기 전에 수렴하면 일찍 종료한다.
    • 수렴에 문제가 있다면 tol 매개변수 값을 늘리는 것이 한 가지 방법이다. 이 매개변수는 수렴을 결정한느 클러스터 내 제곱 오차 합의 변화량에 대한 허용 오차를 조정한다. 위 코드에서는 1e-04(=0.0001)로 설정하였다.
  • k-평균의 한 가지 문제는 하나 이상의 클러스터가 비어 있으 ㄹ수 있다는 점이다. 이런 문제는 k-메도이드(k-medoid)나 C-평균(C-means)에는 나타나지 않는다.
    • 사이킷런의 k-평균에는 이 문제가 고려되어 있다. 한 클러스터가 비어있다면 알고리즘이 빈 클러스터의 센트로이드에서 가장 멀리 떨어진 샘플을 찾고 그 다음 가장 먼 포인트에 센트로이드를 다시 할당한다.
  • 앞선 코드를 시각화 하면 다음과 같다.

plt.scatter(X[y_km==0, 0], X[y_km==0, 1], s=50, c='lightgreen', marker='s', edgecolor='black', label='cluster 1')
plt.scatter(X[y_km==1, 0], X[y_km==1, 1], s=50, c='orange', marker='o', edgecolor='black', label='cluster 2')
plt.scatter(X[y_km==2, 0], X[y_km==2, 1], s=50, c='lightblue', marker='v', edgecolor='black', label='cluster 3')
plt.scatter(km.cluster_centers_[:, 0], km.cluster_centers_[:, 1], s=250, c='red', marker='*', edgecolor='black', label='centroids')
plt.legend(scatterpoints=1)
plt.grid()
plt.tight_layout()
plt.show()

k-평균++로 초기 클러스터 센트로이드를 똑똑하게 할당

  • k-평균 알고리즘에서 초기 센트로이드가 좋지 않게 선택되면 이따금 나쁜 군집 결과를 만들거나 수렴이 느려진다.
    • 이 문제를 해결하는 한 가지 방법은 같은 데이터셋에서 k-평균 알고리즘을 여러 번 실행하여 SSE 입장에서 가장 성능이 좋은 모델을 선택하는 것이다.
    • 또 다른 방법은 k-평균++ 알고리즘을 통해 초기 센트로이드가 서로 멀리 떨어지도록 위치하는 것이다. 이는 기본 k-평균보다 일관되고 훌륭한 결과를 만든다.
  • k-평균++ 초기화는 다음과 같이 정리할 수 있다.
    • 선택한 k개의 센트로이드를 저장할 빈 집합 M 을 초기화 한다.
    • 입력 샘플에서 첫 번째 센트로이드 \mu^{(i)} 를 랜덤하게 선택하고 M 에 할당한다.
    • M 에 있지 않은 각 샘플 x^{(i)} 에 대해 M 에 있는 센트로이드까지 최소 제곱 거리 d(x^{(i)}, M)^{2} 를 찾는다.
    • 다음 식과 같은 가중치가 적용된 확률 분포를 사용하여 다음 센트로이드 \mu^{(p)} 를 랜덤하게 선택한다.
      • {d(\mu^{(p)}, M)^{2} \over \sum_{i} d(x^{(i)}, M)^{2}}
    • k개의 센트로이드를 선택할 때까지 단계 2-3을 반복한다.
    • 그 다음 기본 k-평균 알고리즘을 수행한다.
  • 사이킷런의 Kmeans 클래스로 k-평균++를 사용하려면 init 매개변수를 ‘k-means++’로 지정하기만 하면 된다.
    • 사실 ‘k-means++’가 실전에서 매우 권장되기 때문에 init 매개변수의 기본값이다.

직접 군집 vs 간접 군집

  • 직접 군집(hard clustering)은 데이터셋의 샘플이 정확히 하나의 클러스터에 할당되는 알고리즘 종류를 말한다. 이전 절세 설명한 k-평균 알고리즘이 이에 해당한다.
    • 반면 간접 군집(soft clustering) 또는 퍼지 군집(fuzzy clustering) 알고리즘은 샘플을 하나 이상의 클러스터에 할당한다.
    • 간접 군집의 대표적인 예는 퍼지 C-평균(Fuzzy C-Means, FCM) 알고리즘이다.
  • 원래 아이디어는 조셉 던(Joseph C. Dunn)이 k-평균을 개선하기 위해 퍼지 군집의 초기 버전을 처음 제안한 1970년대로 거슬러 올라간다. 약 10년 정도 지난 후에 제임스 베즈덱(James C. Bezdek)이 퍼지 군집 알고리즘의 개선 버전을 공개했고, 이것이 FCM 알고리즘이라 불리게 되었다.
  • FCM 처리 단계는 k-평균과 매우비슷하다. 다만 포인트가 직접적으로 클러스터에 할당되는 것을 각 클러스터에 속할 확률로 바꾼다.
    • k-평균에서는 샘플 x의 소속을 이진 희소벡터로 표현할 수 있다.

\left[ \begin{array}{rrr} \mu^{(1)} \to 0 \\ \mu^{(2)} \to 1 \\ \mu^{(3)} \to 0 \end{array} \right]

  • 여기서 값이 1인 인덱스 위치가 이 샘플이 할당된 클러스터 센트로이드를 나타낸다.
  • 이와 다르게 FCM의 클래스 소속 벡터는 다음과 같이 표현할 수 있다.

\left[ \begin{array}{rrr} \mu^{(1)} \to 0.10 \\ \mu^{(2)} \to 0.85 \\ \mu^{(3)} \to 0.05 \end{array} \right]

  • 여기서 각 값은 [0, 1] 범위 안에 있으며 각 클러스터 센트로이드의 확률을 나타낸다. 한 샘플에 대한 클래스 소속 확률의 합은 1이다.
  • k-평균 알고리즘과 비슷하게 FCM 알고리즘은 네 단계로 요약할 수 있다.
    • 센트로이드 개수 k를 지정하고 랜덤하게 각 포인트에 대해 클러스터 확률을 할당한다.
    • 클러스터 센트로이드 \mu^{(j)}, j \in \{ 1, ... , k \} 를 계산한다.
    • 각 샘플에 대해 클러스터 소속 확률을 업데이트 한다.
    • 클러스터 확률이 변하지 않거나 사용자가 지정한 허용 오차나 최대 반복 횟수에 도달할 때까지 2-3을 반복한다.
  • FCM의 목적 함수 J_{m} sms k-평균에서 최소화하는 클러스터 내 제곱 오차합과 매우 비슷하다.

J_{m} = \sum_{i=1}^{n} \sum_{j=1}^{k} w^{m(i, j)} \| x^{(i)} - \mu^{(j)} \|_{2}^{2}

  • 클러스터 소속 가중치 w^{(i, j)} 는 k-평균처럼 이진값이 아니라 실수값이다.
  • w^{(i, j)} 는 추가적인 지수를 포함한다.
    • 퍼지 계수(fuzziness coefficient) 또는 퍼지 지수(fuzzifier)라고 하는 지수 m 은 1보다 크거나 같으며(일반적으로 m=2 ) 퍼지의 정도를 제어한다.
    • m 이 클수록 클러스터 소속 확률 w^{(i, j)} 가 작아져 더 복잡한(fuzzier) 클러스터를 만든다.
  • 클러스터 소속 확률은 다음과 같이 계산한다.

w^{(i, j)} = [ \sum_{p=1}^{k} ({\| x^{(i)} - \mu^{(j)} \|_{2} \over \| x^{(i)} - \mu^{(p)} \|_{2}})^{{2 \over m-1}} ]^{-1}

  • 예컨대 세 개의 클러스터 중심을 선택한다면 샘플 x^{(i)} \mu^{(j)} 클러스터에 속할 확률은 다음과 같이 계산할 수 있다.

w^{(i, j)} = [ ({\| x^{(i)} - \mu^{(j)} \|_{2} \over \| x^{(i)} - \mu^{(1)} \|_{2}})^{{2 \over m-1}} + ({\| x^{(i)} - \mu^{(j)} \|_{2} \over \| x^{(i)} - \mu^{(2)} \|_{2}})^{{2 \over m-1}} + ({\| x^{(i)} - \mu^{(j)} \|_{2} \over \| x^{(i)} - \mu^{(3)} \|_{2}})^{{2 \over m-1}} ]^{-1}

  • 클러스터 중심 \mu^{(j)} 는 샘플의 소속 확률 (w^{m(i, j)} )을 가중치로 주어 클러스터에 속한 모든 샘플의 평균으로 계산된다.

\mu^{(j)} = {\sum_{i=1}^{n} w^{m(i, j)} x^{(i)} \over \sum_{i=1}^{n} w^{m(i, j)}}

  • 클러스터 소속 확률을 계산하는 공식을 보면 FCM의 각 반복이 k-평균 반복보다 비용이 더 많이 든다는 것을 알 수 있다. 하지만 FCM은 전형적으로 수렴에 도달하기까지 반복 횟수가 적게 든다.
  • 안타깝지만 FCM 알고리즘은 아직 사이킷런에 구현되어 있지 않다. 실제로는 k-평균과 FCM이 매우 비슷한 군집 결과를 만든다고 알려져 있다.

엘보우 방법을 사용하여 최적의 클러스터 개수 찾기

  • 비지도 학습에서 가장 어려운 점 하나는 최종 답을 모른다는 것이다. 데이터셋에 진짜 클래스 레이블이 없기 때문에 지도 학습의 성능 평가를 위해 사용한 기법들을 적용할 수 없다.
  • 군집 품질을 평가하려면 알고리즘 자체의 지표를 사용해야 한다. 예컨대 k-평균 군집의 성능을 비교하기 위해 앞서 언급한 클래스 내 SSE(왜곡) 를 사용한다.
    • 사이킷런을 사용하면 클래스 내 SSE를 직접 계산할 필요가 없는데, inertia_ 속성에 이미 계산되어 있기 때문이다.
  • 클래스 내 SSE를 바탕으로 엘보우 방법이라 하는 그래프를 사용하여 문제에 최적인 클러스터 개수 k를 추정할 수 있다.
    • 직관적으로 k가 증가하면 왜곡은 줄어들 것이다. 샘플이 할당된 센트로이드에 더 가까워지기 때문이다. 엘보우 방법 이면에 있는 아이디어는 왜곡이 빠르게 증가하는 지점의 k값을 찾는 것이다.
    • k값을 바꾸어 가며 왜곡 값을 그래프로 그리면 명확하게 알 수 있다.
distortions = []

for i in range(1, 11):
   km = KMeans(n_clusters=i, init='k-means++', n_init=10, max_iter=300, random_state=0)
    km.fit(X)
    distortions.append(km.inertia_)

plt.plot(range(1, 11), distortions, marker='o')
plt.xlabel('Number of clusters')
plt.ylabel('Distortion')
plt.tight_layout()
plt.show()

  • 위 코드의 결과를 보면 k=3에서 엘보우가 나타난다는 것을 볼 수 있다. 그러므로 이 데이터셋에서는 k=3이 좋은 선택이 된다.

실루엣 그래프로 군집 품질을 정량화

  • 군집 품질을 평가하는 또 다른 방법은 실루엣 분석(silhouette analysis)이다. 이 방법은 k-평균 이외에 이 장 뒤에서 설명할 다른 군집 알고리즘에도 적용할 수 있다.
  • 실루엣 분석은 클러스터 내 샘플들이 얼마나 조밀하게 모여있는지를 측정하는 그래프 도구이다. 데이터셋 샘플 하나에 대한 실루엣 계수(silhouette coeffcient)를 계산하려면 다음 세 단계를 적용한다.
    • 샘플 x^{(i)} 와 동일한 클러스터 내 모든 다른 포인트 사이의 거리를 평균하여 클러스터 응집력(cluster cohesion) a^{(i)} 를 계산한다.
    • 샘플 x^{(i)} 와 가장 가까운 클러스터의 모든 샘플 간 평균 거리고 최근접 클러스터의 클러스터 분리도(cluster separation) b^{(i)} 를 계산한다.
    • 클러스터 응집력과 분리도 사이의 차이를 둘 중 큰 값으로 나누어 실루엣 s^{(i)} 를 다음과 같이 계산한다.
      • s^{(i)} = {b^{(i)} - a^{(i)} \over max \{ b^{(i)}, a^{(i)} \} }
  • 실루엣 계수는 -1과 1 사이의 값을 갖는다.
    • 앞 공식을 보면 클러스터 응집력과 분리도가 같으면(b^{(i)} = a^{(i)} ) 실루엣 계수가 0이 된다.
    • b^{(i)} >> a^{(i)} 이면 이상적인 실루엣 계수 1에 가깝게 된다.
    • b^{(i)} 는 샘플이 다른 클러스터와 얼마나 다른지 나타내고, a^{(i)} 는 클러스터 내 다른 샘플과 얼마나 비슷한지 나타내기 때문이다.
  • 실루엣 계수는 사이킷런의 metric 모델 아래 silhouette_samples 함수로 계산할 수 있다. 또 편의를 위해 silhouette_score 함수를 임포트 할 수 있다.
    • silhouette_scores 함수는 모든 샘플에 걸쳐 평균 실루엣 계수를 계산한다.
  • 다음 코드는 k=3인 k-평균 군집의 실루엣 계수 그래프이다.
import matplotlib.pyplot as plt
from matplotlib import cm
import numpy as np

km = KMeans(n_clusters=3, init='k-means++', n_init=10, max_iter=300, tol=1e-04, random_state=0)
y_km = km.fit_predict(X)
cluster_labels = np.unique(y_km)
n_clusters = cluster_labels.shape[0]
silhouette_vals = silhouette_samples(X, y_km, metric='euclidean')
y_ax_lower, y_ax_upper = 0, 0

yticks = []

for i, c in enumerate(cluster_labels):
   c_silhouette_vals = silhouette_vals[y_km == c]
    c_silhouette_vals.sort()
   y_ax_upper += len(c_silhouette_vals)
    color = cm.jet(float(i)/n_clusters)
    plt.barh(range(y_ax_lower, y_ax_upper), c_silhouette_vals, height=1.0, edgecolor='none', color=color)
   yticks.append((y_ax_lower + y_ax_upper) / 2.)
    y_ax_lower += len(c_silhouette_vals)

silhouette_avg = np.mean(silhouette_vals)

plt.axvline(silhouette_avg, color="red", linestyle="--")
plt.yticks(yticks, cluster_labels + 1)
plt.xlabel('Silhouette coefficient')
plt.ylabel('Cluster')
plt.tight_layout()
plt.show()

  • 위 그래프에서 보이듯이 실루엣 계수의 값이 0에서 멀리 떨어져 있다. 이는 군집이 잘 되었다는 것을 나타낸다.
  • 나쁜 군집에 대해 실루엣 그래프가 어떻게 보이는지 알아보기 위해 2개의 센트로이드로 k-평균 알고리즘을 적용해 보자.
    • (앞선 내용과 동일하므로 코드 생략)

  • 이때의 실루엣 그래프는 아래와 같다. 이는 군집 결과가 나쁘거나 최적이 아니라는 뜻이 된다.
    • (앞선 내용과 동일하므로 코드 생략)

계층적인 트리로 클러스터 조직화

  • 여기서는 프로토타입 기반 군집의 또 다른 방법인 계층 군집(hierarchical clustering)을 알아보겠다.
  • 계층 군집 알고리즘의 한 가지 장점은 덴드로그램(dendrogram) (이진 트리 형태로 계층 군집을 시각화 할 수 있는 도구)을 그릴 수 있다는 것이다. 또 다른 장점은 클러스터 개수를 미리 지정할 필요가 없다는 것이다.
  • 계층 군집의 두 가지 방법은 병합 계층 군집(agglomerative hierarchical clustering)과 분할 계층 군집(divisive hierarchical clustering)이다.
    • 분할 군집에서는 전체 샘플을 포함하는 하나의 클러스터에서 시작하여 더 작은 클러스터로 반복적으로 나누고, 이를 클러스터 안에 샘플이 하나만 남을 때까지 한다.
    • 병합 군집은 이와 반대인데, 먼저 각 샘플이 독립적인 클러스터가 되고 하나의 클러스터가 남을 때까지 가장 가까운 클러스터를 합친다.

상향식으로 클러스터 묶기

  • 병합 계층 군집의 두 가지 기본 알고리즘은 단일 연결(single linkage)와 완전 연결(complete linkage)이다.
    • 단일 연결을 사용하면 클러스터 쌍에서 가장 비슷한 샘플 간 거리를 계산한다. 그 다음 거리가 가장 작은 두 클러스터를 합친다.
    • 완전 연결 방식은 단일 연결과 비슷하지만 클러스터 쌍에서 가장 비슷한 샘플을 비교하는게 아니라 가장 비슷하지 않은 샘플을 비교하여 병합을 수행한다.
  • 병합 계층 군집에서 널리 사용하는 다른 알고리즘은 평균 연결(average linkage)와 와드 연결(Ward’s linkage)이다.
    • 평균 연결은 두 클러스터에 있는 모든 샘플 사이의 평균 거리가 가장 작은 클러스터 쌍을 합친다.
    • 와드 연결은 클러스터 내 SSE가 가장 작게 증가하는 두 클러스터를 합친다.

  • 완전 연결 계층 군집은 반복적인 과정이며, 다음 단계로 요약할 수 있다.
    1. 모든 샘플의 거리 행렬을 계산한다.
    2. 모든 데이터 포인트를 단일 클러스터로 표현한다.
    3. 가장 비슷하지 않은 (멀리 떨어진) 샘플 사이 거리에 기초하여 가장 가까운 두 클러스터를 합친다.
    4. 유사도 행렬을 업데이트 한다.
    5. 하나의 클러스터가 남을 때까지 2-4단계를 반복한다.
  • 거리 행렬을 계산하는 바법을 알아보기 위해 샘플 데이터를 만들어 보자.
import pandas as pd
import numpy as np

np.random.seed(123)
variables = ['X', 'Y', 'Z']
labels = ['ID_0', 'ID_1', 'ID_2', 'ID_3', 'ID_4']
X = np.random.random_sample([5,3]) * 10
df = pd.DataFrame(X, columns=variables, index=labels)

거리 행렬에서 계층 군집 수행

  • 계층 군집 알고리즘의 입력에 사용할 거리 행렬을 계산하기 위해 사이파이의 spatial.distance 모듈에서 pdist 함수를 사용하겠다.
from scipy.spatial.distance import pdist, squareform

row_dist = pd.DataFrame(squareform(pdist(df, metric='euclidean')), columns=labels, index=labels)
  • 위 코드에서 특성 X, Y, Z를 기반으로 데이터셋 모든 샘플 쌍의 유클리디안 거리를 계산한다.
  • pdist 함수는 축약된 거리 행렬을 반환한다. 이를 squareform 함수에 넣어 샘플간 거리 대칭 행렬을 만든다.
  • 그 다음 사이파이 cluster.hierarchy 모듈의 linkage 함수를 사용해서 완전 연결 병합을 적용해 보자. 이 함수는 연결 행렬을 반환한다.
from scipy.cluster.hierarchy import linkage

row_clusters = linkage(df.values, method='complete', metric='euclidean')
  • 군집 결과를 자세히 보기 위해 판다스의 DataFrame으로 변환하자.
pd.DataFrame(row_clusters, columns=['row label 1', 'row label 2', 'distance', 'no. of items in clust.'], index=['cluster %d' %(i+1) for i in range(row_clusters.shape[0])])
  • 연결 행렬을 계산했으므로 이 결과를 덴드로그램으로 그릴 수 있다.
from scipy.cluster.hierarchy import dendrogram

row_dendr = dendrogram(row_clusters, labels=labels)

plt.tight_layout()
plt.ylabel('Euclidean distance')
plt.show()

히트맵에 덴드로그램 연결

  • 실전 애플리케이션에서는 계층 군집 덴드로그램이 히트맵(heat map)과 함께 자주 사용된다. 히트맵을 사용하면 샘플 행렬의 개별 값을 색으로 표현할 수 있다.
  • 덴드로그램을 히트맵에 추가하는 것은 조금 까다롭다.
  • 새로운 figure 객체를 만들고 add_axes 메서드를 사용해서 덴드로그램의 x, y, width, height를 지정한다. 그 다음 덴드로그램을 반시계 방향으로 90도 회전시킨다.
fig = plt.figure(figsize=(8,8), facecolor='white')
axd = fig.add_axes([0.09, 0.1, 0.2, 0.6])
# matbplolib 버전이 1.5.1 보다 낮을 때는 'right'를 사용할 것
row_dendr = dendrogram(row_clusters, orientation='left')
  • 그 다음 파이썬 딕셔너리인 덴드로그램 객체의 leaves 키에서 얻은 클러스터 레이블을 따라 원본 DataFrame에 있는 데이터를 재정렬한다.
df_rowclust = df.iloc[row_dendr['leaves'][::-1]]
  • 이제 재정렬된 DataFrame에서 히트맵을 만들고 덴드로그램 다음에 위치시킨다.
axm = fig.add_axes([0.23, 0.1, 0.6, 0.6])
cax = axm.matshow(df_rowclust, interpolation='nearest', cmap='hot_r')
  • 마지막으로 미려하게 만들기 위해 축 눈금을 제거하고 그래프 테두리를 감춘다. 컬러 막대를 추가하고 특성과 샘플 이름을 각각 x축과 y축 눈금의 레이블로 할당한다.
axd.set_xticks([])
axd.set_yticks([])

for i in axd.spines.values():
    i.set_visible(False)

axm.set_xticklabels([''] + list(df_rowclust.columns))
axm.set_yticklabels([''] + list(df_rowclust.index))

plt.show()

사이킷런에서 병합 군집 적용

  • 앞서 사이파이를 사용하여 병합 계층 군집을 수행하는 방법을 배웠는데, 사이킷런에도 AgglomerativeClustering 클래스가 구현되어 있으며 원하는 클러스터 개수를 지정할 수 있다.
    • 이 클래스를 사용하면 계층 군집의 트리 성장을 일찍 멈추게 할 수 있다.
from sklearn.cluster import AgglomerativeClustering

ac = AgglomerativeClustering(n_clusters=2, affinity='euclidean', linkage='complete')

labels = ac.fit_predict(X)

DBSCAN을 사용하여 밀집도가 높은 지역 찾기

  • DBSCAN(Density-Based Spatial Clustering of Applications with Noise) 군집 알고리즘은 k-평균처럼 원형 클러스터를 가정하지 않는다. 또 임계치를 수동으로 지정해야 하는 계층적인 방식으로 데이터셋을 나누지 않는다.
    • 이름이 의미하듯 밀집도 기반 군집 알고리즘은 샘플이 조밀하게 모인 지역에 클러스터 레이블을 할당한다.
    • DBSCAN에서 밀집도란 특정 반경 \varepsilon 안에 있는 샘플 개수로 정의한다.
  • DBSCAN 알고리즘에는 다음 조건에 따라 샘플에 특별한 레이블이 할당된다.
    • 어떤 샘플의 특정 반경 \varepsilon 안에 있는 이웃 샘플이 지정된 개수(MinPts) 이상이면 핵심 샘플(core point)이 된다.
    • \varepsilon 이내에 MinPts 보다 이웃이 적지만 다른 핵심 샘플의 반경 \varepsilon 안에 있으면 경계 샘플(border point)이 된다.
    • 핵심 샘플과 경계 샘플이 아닌 다른 모든 샘플은 잡음 샘플(noise point)이 된다.
  • 핵심 샘플, 경계 샘플, 잡음 샘플로 레이블을 할당한 후에는 DBSCAN 알고리즘을 다음 두 단계로 요약할 수 있다.
    1. 개별 핵심 샘플이나 (\varepsilon 이내에 있는 핵심 샘플을 연결한) 핵심 샘플의 그룹을 클러스터로 만든다.
    2. 경계 샘플을 해당 핵심 샘플의 클러스터에 할당한다.
  • DBSCAN의 핵심 샘플, 경계 샘플, 잡음 샘플은 아래 그림과 같다.

  • DBSCAN의 대표적인 장점 중 하나는 k-평균처럼 클러스터 모양을 원형으로 가정하지 않는다는 것이다. 또 DBSCAN은 k-평균이나 계층 군집과는 달리 모든 샘플을 클러스터에 할당하지 않고 잡음 샘플을 구분하는 능력이 있다.
  • 반달 모양의 데이터셋을 만들어 k-평균 군집, 계층 군집, DBSCAN을 비교해 보자.
from sklearn.datasets import make_moons

X, y = make_moons(n_samples=200, noise=0.05, random_stat=0)

plt.scatter(X[:, 0], X[:, 1])
plt.tight_layout()
plt.show()

  • k-평균 알고리즘과 완전 연결 병합 군집 알고리즘이 반달 모양을 별도의 클러스터로 구분할 수 있는지 확인해 보자
f, (ax1, ax2) = plt.subplots(1, 2, figsize=(8,3))

km = KMeans(n_clusters=2, random_state=0)
y_km = km.fit_predict(X)

ax1.scatter(X[y_km==0, 0], X[y_km==0, 1], c='lightblue', edgecolor='black', marker='o', s=40, label='cluster 1')
ax1.scatter(X[y_km==1, 0], X[y_km==1, 1], c='red', edgecolor='black', marker='s', s=40, label='cluster 2')
ax1.set_title('K-means clustering')

ac = AgglomerativeClustering(n_clusters=2, affinity='euclidean', linkage='complete')
y_ac = ac.fit_predict(X)

ax2.scatter(X[y_ac==0, 0], X[y_ac==0, 1], c='lightblue', edgecolor='black', marker='o', s=40, label='cluster 1')
ax2.scatter(X[y_ac==1, 0], X[y_ac==1, 1], c='red', edgecolor='black', marker='s', s=40, label='cluster 2')
ax2.set_title('K-Agglomerative clustering')

plt.legend()
plt.tight_layout()
plt.show()

  • 두 알고리즘 모두 두 클러스터를 구분하는 결과가 좋지 못하다.
  • 이 데이터셋에 DBSCAN 알고리즘을 적용해서 밀집도 기반 방식이 어떤 결과를 보여주는지 살펴보자.
from sklearn.cluster import DBSCAN

db = DBSCAN(eps=0.2, min_samples=5, metric='euclidean')
y_db = db.fit_predict(X)

plt.scatter(X[y_db==0, 0], X[y_db==0, 1], c='lightblue', edgecolor='black', marker='o', s=40, label='cluster 1')
plt.scatter(X[y_db==1, 0], X[y_db==1, 1], c='red', edgecolor='black', marker='s', s=40, label='cluster 2')
plt.legend()
plt.tight_layout()
plt.show()

  • DBSCAN은 성공적으로 반달 모양을 감지했다. 이 예제는 DBSCAN 장점 중 하낭니 임의 형태의 데이터를 처리할 수 있는 능력을 잘 보여준다.
  • DBSCAN의 단점도 이야기해보면 다음과 같다.
    • 데이터셋에서 훈련 샘플 개수가 고정되어 있다 가정하고, 특성 개수가 늘어나면 차원의 저주로 인한 역효과가 증가한다. 특히 유클리디안 거리 측정을 사용할 때 문제가 된다.
    • 차원의 저주(curse of dimensionality)가 DBSCAN 만의 문제는 아니고 유클리디안 거리 측정을 사용하는 다른 군집 알고리즘에도 영향을 미친다. 예컨대 k-평균과 계층 군집 알고리즘도 해당한다.
    • DBSCAN이 좋은 군집 결과를 만들려면 두 개의 하이퍼파라미터(MinPts와 \varepsilon )를 최적화 해야 한다. 데이터셋에 있는 밀집 영역의 크기가 많이 차이나면 알맞은 MinPts와 \varepsilon 조합을 찾는 일이 어렵다.
  • 실전에서는 어떤 군집 알고리즘이 주어진 데이터셋에서 최상일지 확실하지 않다. 특히 시각화가 어렵거나 불가능한 고차원 데이터일 때 그렇다.
    • 성공적인 군집 알고리즘은 알고리즘이나 하이퍼파라미터에만 의존하지 않는다. 오히려 적절한 거리 지표를 선택하고 실험 환경을 구성하는데 도움을 줄 수 있는 도메인(domain) 지식이 더 중요할 수 있다.
  • 차원의 저주를 고려하면 군집을 수행하기 전 차원 축소 기법을 적용하는 것이 일반적이다.