본문 바로가기

Deep Learning

[밑바닥부터 시작하는 딥러닝2] Chapter 3. Word2Vec

3장은 '추론 기반 기법' 중 가장 유명한 Word2Vec을 소개하고, 그 구조를 구현하며 이해하는 장입니다. 

3.1 추론 기반 기법과 신경망

3.1.1 통계 기반 기법의 문제점

  • 대규모 말뭉치를 다룰 때(ex-100만개 이상의 단어들) 100만 x 100만 동시발생 행렬에 SVD를 적용하는 것은 현실적이지 않음. (SVD를 $n \times n$ 행렬에 적용하는 비용은 $O(n^3)$)
  • 추론 기반 기법(신경망)에서는 소량(미니배치)의 학습 샘플씩 반복해서 학습해가며 가중치를 갱신한다.
  • 통계 기반 기법은 학습 데이터를 한번에(배치로) 처리하지만, 추론 기반 기법은 학습 데이터의 일부를 사용해(미니배치) 순차적으로 학습한다. 
  • 따라서 계산량이 큰 작업을 처리하기도 용이하고, GPU를 이용한 병렬 계산도 가능해 학습 속도의 이점이 있다. 

3.1.2 추론 기반 기법 개요

  • 주변 단어(맥락)가 주어졌을 때 가운데 빈 칸에 무슨 단어가 들어가는지를 추측하는 작업을 반복함으로써 단어의 출현 패턴을 학습한다.
  • 어떤 모델(일반적으로 신경망)은 맥락 정보를 입력으로 받아, 각 단어의 출현 확률을 출력하며, 올바른 추측을 내놓도록 학습된다.
  • 추론 기반 기법도 통계 기반 기법처럼 분포 가설에 기초한다.

3.1.3 신경망에서의 단어 처리

  • 원핫 표현(One-Hot vector) : 벡터의 원소 중 하나만 1이고 나머지는 모두 0인 벡터

  • 총 어휘 수만큼의 크기를 갖는 벡터를 준비하고, 단어ID의 index에 1을, 나머지에는 모두 0을 설정한다. 그럼 단어들이 모두 고정 길이 벡터로 변환되며, 따라서 신경망의 입력층의 뉴런의 수를 '고정'할 수 있다.

  • [그림 3-5]처럼 입력층의 뉴런은 차례로 7개의 단어들(you, say, ...)에 대응된다. 

  • [그림 3-7]은 원핫 벡터가 된 단어 하나가 완전연결계층을 통해 은닉층으로 변환된 것을 간단히 도식화한 것이다.
  • 아래는 단어 ID가 0인 단어('you')를 원 핫 벡터로 표현한 다음, 완전연결계층(FC Layer)을 통과시켜 변환하는 코드이다. (MatMul layer를 사용해 순전파를 수행해도 동일한 결과를 얻을 수 있음.) 
  • c는 원핫벡터이므로 결국 c와 W의 행렬 곱은 다음 그림처럼 가중치의 행벡터 하나를 "뽑아낸" 것 과 같다.

class MatMul:
    def __init__(self, W):
        self.params = [W]
        self.grads = [np.zeros_like(W)]
        self.x = None

    def forward(self, x):
        W, = self.params
        out = np.dot(x, W)
        self.x = x
        return out

    def backward(self, dout):
        W, = self.params
        dx = np.dot(dout, W.T)
        dW = np.dot(self.x.T, dout)
        self.grads[0][...] = dW
        return dx

# version 1 
import numpy as np
c = np.array([[1, 0, 0, 0, 0, 0, 0]]) # 입력
W = np.random.randn(7, 3) # 가중치
h = np.matmul(c, W) # 중간 노드
print(h) # [[-1.0856306   0.99734545  0.2829785 ]]

# version 2 - using MatMul
layer = MatMul(W)
h = layer.forward(c)
print(h) # [[-1.0856306   0.99734545  0.2829785 ]]

3.2 단순한 Word2Vec

3.2.1 CBOW 모델의 추론 처리

  • CBOW 모델 : 주변 단어인 맥락으로부터 중앙 단어인 타깃을 추측하는 용도의 신경망이다. 따라서 입력 : 맥락(context, 단어들의 목록), 출력 : 타깃(target word)가 된다. 

  • 가장 먼저 맥락을 원핫 벡터로 변환해 CBOW 모델이 처리할 수 있도록 준비한다. 
  • [그림 3-9]를 보면 입력층이 2개 있고(2개의 단어를 사용), 두 입력층은 동일한 완전연결계층(가중치 : $\mathbf{W}_{in}$)을 거쳐 은닉층 뉴런으로 변환된다. 이후 은닉층에서 출력층 뉴런으로의 변환은 다른 완전연결계층(가중치 : $\mathbf{W}_{out}$)이 처리한다. 
  • 은닉층의 뉴런은 입력층의 완전연결계층에 의해 변환된 값이 되는데, 입력층이 여러개일 경우 전체를 그냥 '평균'내면 된다. → 완전연결계층에 의한 첫 번째 입력층이 $\mathbf{h}_1$으로 변환되고, 두 번째 입력층이 $\mathbf{h}_2$로 변환되었다고 하면 최종 은닉층 뉴런은 $\frac{1}{2}(\mathbf{h}_1 + \mathbf{h}_2)$가 된다. 
  • 마지막 출력층을 보면 뉴런이 다시 7개인데, 각 뉴런 하나하나가 각각의 단어에 대응되는 '점수'가 된다. 값에 softmax 함수를 적용해 '확률'로 해석가능하기 때문에 해당 점수가 높을수록 출력 확률이 높음을 의미한다. 

  • 입력층 → 은닉층으로 변환하는 작업은 가중치 $\mathbf{W}_in$를 가진 완전연결계층에 의해 이뤄진다. 이 가중치 행렬은 $7 \times 3$ 행렬이며, 각 행이 단어의 분산 표현 그 자체이다. 따라서 학습이 진행될수록 맥락에서 출현할 단어를 잘 추측하는 방향으로 이 분산표현들이 갱신된다. 
  • 핵심은 은닉층의 뉴런 수를 입력층의 뉴런 수보다 적게 하여 단어예측에 필요한 정보를 '간결하게' 담는것이다.

  • CBOW 모델을 '계층 관점'에서 그리면 위의 [그림 3-11]과 같다. 가장 앞단에는 2개의 MatMul 계층이 있고, 이 두 계층의 출력을 더하고 1/2를 곱해 '평균'인 은닉층 뉴런을 계산한다. 은닉층 뉴런에 또 다른 MatMul 계층이 적용되어 최종 점수가 출력된다. (편향을 사용하지 않는 FC layer의 처리는 MatMul layer이 순전파와 같다.)
# sample context data
c0 = np.array([[1, 0, 0, 0, 0, 0, 0]])
c1 = np.array([[0, 1, 0, 0, 0, 0, 0]])

# 가중치 초기화
W_in = np.random.randn(7, 3)
W_out = np.random.randn(3, 7)

# 계층 생성
in_layer0 = MatMul(W_in) # 입력층 측의 MatMul 계층은 W_in 가중치를 공유한다.
in_layer1 = MatMul(W_in)
out_layer = MatMul(W_out)

# 순전파
h0 = in_layer0.forward(c0)
h1 = in_layer1.forward(c1)
h = 0.5 * (h0 + h1)
s = out_layer.forward(h)

print(s) # [[ 0.91444324 -1.832766    0.95765727 -1.16474991 -3.3606458   2.40740423  -0.335057 ]]
  • CBOW 모델은 위와 같이 추론이 진행되며, 활성화 함수를 사용하지 않는 간단한 구성의 신경망이다. 

3.2.2 CBOW 모델의 학습

  • CBOW 모델의 출력층 점수에 softmax 함수를 적용하면 맥락(전후 단어)이 주어졌을 때 중앙에 어떤 단어가 출현할지의 '확률'을 얻을 수 있다. 

  • you와 goodbye가 맥락으로 주어졌고, 정답 label은 'say'가 되어야 한다. CBOW 모델이 올바른 예측을 할 수 있도록 학습되었다면 가중치 $\mathbf{W}_in$에 단어의 출현 패턴을 제대로 파악한 벡터가 학습되었을 것이다.
  • 이처럼 다중 클래스 분류를 수행하는 신경망이기 때문에 소프트맥스와 교차 엔트로피 오차만 이용하면 된다. softmax 함수로 점수를 확률로 변환하고, 그 확률과 정답 레이블로부터 교차 엔트로피 오차를 구한 값을 손실로 사용해 학습을 진행한다. 

  • 최종적으로 CBOW 모델에 softmax + cross entropy loss가 적용된 Softmax with Loss 계층을 추가해 최종적으로 다음과 같은 신경망 구조를 띈다. 

3.2.3 Word2Vec의 가중치와 분산 표현

  • 입력 측 가중치 $\mathbf{W}_{in}$ : 각 단어의 분산 표현이 행 방향으로 저장되어있음.
  • 출력 측 가중치 $\mathbf{W}_{out}$ : 각 단어의 분산 표현이 열 방향(수직)으로 저장되어있음.

  • Word2Vec에서는 최종적으로 단어의 분산 표현으로 입력 층의 가중치 $\mathbf{W}_{in}$를 이용한다. (GloVe에서는 두 가중치를 더했을 때 좋은 결과를 얻었다고 함)

3.3 학습 데이터 준비

3.3.1 맥락과 타깃

  • 입력(input) : 맥락(context), 정답 레이블(label) : 중앙 단어(target).

  • 위 그림처럼 말뭉치 안의 모든 단어에 대해 목표로 하는 단어를 target으로, 그 주변 단어를 contexts로 뽑아낸다. 여기에서 contexts의 수는 2개로 설정되어 있으나 window size를 조절해 더 늘릴 수 있다. 하지만 타깃은 오로지 1개 뿐이다.
def create_contexts_target(corpus, window_size=1):
  target = corpus[window_size:-window_size]
  contexts = []

  for idx in range(window_size, len(corpus)-window_size):
    cs = []
    for t in range(-window_size, window_size + 1):
      if t == 0:
        continue
      cs.append(corpus[idx + t])
    contexts.append(cs)
  return np.array(contexts), np.array(target)
contexts, target = create_contexts_target(corpus, window_size=1)

  • create_contexts_target(corpus, window_size) 함수를 수행하면 [그림 3-17] 형태의 넘파이 다차원 배열을 얻을 수 있다.

3.2.2 원핫 표현으로 변환

  • 위의 코드에서 여전히 각 단어는 단어 id로 표현되어있기 때문에 이를 원핫으로 변환해준다. 
def convert_one_hot(corpus, vocab_size): 
  N = corpus.shape[0]

  if corpus.ndim == 1: # 1차원 배열
    one_hot = np.zeros((N, vocab_size), dtype=np.int32)
    for idx, word_id in enumerate(corpus):
      one_hot[idx, word_id] = 1
  elif corpus.ndim == 2: # 2차원 배열
    C = corpus.shape[1]
    one_hot = np.zeros((N, C, vocab_size), dtype=np.int32)
    for idx_0, word_ids in enumerate(corpus):
      for idx_1, word_id in enumerate(word_ids):
        one_hot[idx_0, idx_1, word_id] = 1

  return one_hot
  
text = "You say goodbye and I say hello."
corpus, word_to_id, id_to_word = preprocess(text) 
contexts, target = create_contexts_target(corpus, window_size=1)
vocab_size = len(word_to_id)
target = convert_one_hot(target, vocab_size)
contexts = convert_one_hot(contexts, vocab_size)

3.4 CBOW 모델 구현

  • 초기화 메서드(__init__) : 인수로 어휘 수와 은닉층 뉴런 수를 받음. 또 필요한 계층인 입력 측의 MatMul 계층 2개, 출력 측의 MatMul 계층 1개, Softmax with Loss 계층 1개를 생성한다. 이때 입력 측의 맥락을 처리하는 MatMul 계층은 맥락에서 사용하는 단어의 수(=window size)만큼 만들어야 하며 같은 가중치를 이용하도록 초기화한다. 추가로 이 신경망에서 사용되는 매개변수와 기울기를 인스턴스 변수인 params와 grads 리스트에 각각 모아둔다. 
  • 이 코드 기준으로는 params 리스트에 같은 가중치가 여러 개 존재하게 되는데, 이러면 Adam이나 Momentum 등의 옵티마이저 처리가 본래의 동작과 달라진다. 따라서 Trainer class 내부에서는 매개변수 갱신 시 그 중복을 없애는 간단한 작업이 추가되어있다. (def remove_duplicate(params, grads) 참고)
  • 신경망 순전파(forward) : 인수로 contexts와 target을 받아 손실(loss)를 반환한다. 이때 contexts는 3차원 넘파이 배열이라고 가정한다.(여기 예제 기준으로는 [6, 2, 7] = (mini-batch, window size, vocab length or one-hot vector))
  • 신경망 역전파(backward) : 1에서 시작해 softmax with loss 계층에 입력되고, 그 역전파 출력 ds를 출력 측 MatMul 계층으로 입력한다. 그 뒤 $\times$와 $+$ 연산 역전파를 진행한다. 
  • 각 매개변수의 기울기를 인스턴스 변수 grads에 모아뒀기 때문에 forward() 메서드를 호출한 다음 backward() 메서드를 실행하기만 해도 grads 리스트의 기울기가 갱신된다.
class SimpleCBOW:
  def __init__(self, vocab_size, hidden_size):
    V, H = vocab_size, hidden_size
    # 가중치 초기화 
    W_in = 0.01 * np.random.randn(V, H).astype('f')
    W_out = 0.01 * np.random.randn(H, V).astype('f')

    # 계층 생성
    self.in_layer_0 = MatMul(W_in)
    self.in_layer_1 = MatMul(W_in)
    self.out_layer = MatMul(W_out)
    self.loss_layer = SoftmaxWithLoss()

    # 모든 가중치와 기울기를 리스트에 모은다.
    layers = [self.in_layer_0, self.in_layer_1, self.out_layer]
    self.params, self.grads = [], []
    for layer in layers:
      self.params += layer.params
      self.grads += layer.grads
    
    # 인스턴스 변수에 단어의 분산 표현을 저장한다.
    self.word_vecs = W_in

  def forward(self, contexts, target):
    h0 = self.in_layer_0.forward(contexts[:, 0]) # contexts : (6, 2, 7) = (mini_batch, contexts window, one-hot vector)
    h1 = self.in_layer_1.forward(contexts[:, 1])
    h = (h0 + h1) * 0.5
    score = self.out_layer.forward(h)
    loss = self.loss_layer.forward(score, target)
    return loss
  
  def backward(self, dout=1):
    ds = self.loss_layer.backward(dout)
    da = self.out_layer.backward(ds)
    da *= 0.5
    self.in_layer_1.backward(da)
    self.in_layer_0.backward(da)
    return None

3.4.1 학습 코드 구현

  • 구체적으로 필요한 모든 코드(Trainer, Adam, SimpleCBOW, etc)는 맨 밑에 종합해서 작성해두었습니다. 
  • 여기에서는 Adam optimizer를 사용해 1장에서 설명한 방식으로 학습 데이터로부터 미니배치를 선택하고, 신경망에 입력해 기울기를 구하고, 그 기울기를 Optimizer에 넘겨 매개변수를 갱신하는 작업을 수행했습니다.  
vocab_size = len(word_to_id)
window_size = 1
hidden_size = 5
batch_size = 3
max_epoch = 1000

model = SimpleCBOW(vocab_size, hidden_size)
optimizer = Adam()
trainer = Trainer(model, optimizer)
trainer.fit(contexts, target, max_epoch, batch_size)
trainer.plot()

  • 보이는 바와 같이 학습을 거듭할수록 loss가 줄어들었다. 
  • 이제 입력 측 MatMul 계층의 가중치를 꺼내 실제 단어의 의미를 잘 파악한 단어의 분산 표현을 밀집 벡터 형태로 확인해 볼 수 있다.

  • 다만 본 예제에서는 작은 말뭉치로 인해 충분히 좋은 결과가 아니다. 큰 말뭉치로 바꾸면 좋은 결과를 얻을 수 있지만, 처리 효율 면에서 위 코드는 몇가지 문제가 있기 때문에 4장에서 개선할 예정이다. 

3.5 Word2Vec 보충

3.5.1 CBOW 모델과 확률

  • CBOW 모델은 맥락을 주면 타깃 단어가 출현할 확률을 출력한다. 즉, 사후확률 $P(w_t | w_{t-1}, w_{t+1})$를 모델링한다. 이를 활용해 교차 엔트로피 오차 식을 적용해보자. 
  • $$L = -\frac{1}{T} \sum_{t=1}^T logP(w_t | w_{t-1}, w_{t+1})$$
  • 이 손실함수를 가능한 작게 학습하고, 이때의 가중치 매개변수가 우리가 얻고자 하는 단어의 분산 표현이 된다.

3.5.2 skip-gram 모델

  • Word2Vec의 2번째 모델은 skip-gram 모델로써, 맥락을 받고 중앙 단어를 추측하는 CBOW와는 다르게 중앙의 단어(타깃)로부터 주변의 여러 단어(맥락)을 추측한다. 

  • skip-gram 모델의 입력층 : 1개, 출력층 : 맥락의 수만큼 존재. 따라서 각 출력층에서는 개별적으로 손실을 구하고, 이 개별 손실들을 모두 더한 값을 최종 손실로 한다. 
  • Skip-gram 모델의 확률 표기 : $P(w_{t-1}, w_{t+1} | w_t)$ 즉, $w_t$가 주어졌을 때 $w_{t-1}$과 $w_{t+1}$이 동시에 일어날 확률을 의미한다. 이때 skip-gram에서는 맥락의 단어들 간의 조건부 독립성을 가정하고 다음과 같이 분해한다. $P(w_{t-1}, w_{t+1}|w_t) = P(w_{t-1}|w_t) P(w_{t+1}|w_t)$
  • skip-gram model Loss : $$L = -\frac{1}{T } \sum_{t=1}^T log P(w_{t-1}|w_t) P(w_{t+1}|w_t) = -\frac{1}{T } \sum_{t=1}^T (log P(w_{t-1}|w_t) + log P(w_{t+1}|w_t))$$
  • CBOW vs skip-gram : skip-gram은 맥락의 수만큼 추측하기 때문에 손실함수가 각 맥락에서 구한 손실의 총합이어야 한다. 반면, CBOW 모델은 타깃 하나의 손실을 구한다. 
  • 두가지 방법론 중 단어 분산 표현의 정밀도 면에서 skip-gram 모델의 결과가 더 좋은 경우가 많았다. 특히 말뭉치가 커질수록 저빈도 단어나 유추 문제 성능 면에서 skip-gram 모델이 더 뛰어난 경향이 있다고 한다. 반면 학습속도 면에선 CBOW 모델이 더 유리하다. 

3.5.3 통계 기반 vs 추론 기반 

  • 통계 기반 기법 : 말뭉치의 전체 통계로부터 1회 학습해 단어의 분산 표현을 얻음.
  • 추론 기반 기법 : 말뭉치를 일부분씩 여러 번 보면서 학습함(미니배치 학습)
  • 만약 어휘에 추가할 새 단어가 생겨 단어의 분산 표현을 갱신해야 하는 상황에서, 통계기반 기법은 계산을 처음부터 다시 해야한다. (동시발생 행렬 -> SVD ...) 그에 반해 추론 기반 기법은 지금까지 학습한 가중치를 초기값으로 매개변수를 다시 학습할 수 있다. 
  • 분산 표현의 성격 : 통계 기반 기법에서는 단어의 유사성이 인코딩된다. 한편 word2vec에서는 단어의 유사성과 단어 사이의 패턴까지도 파악해 인코딩될 수 있다. 
  • 하지만 단어의 유사성을 정량평가해 본 결과, 추론 기반 기법과 통계기반 기법의 우열을 가릴 수 없었다고 함.
  • 또한 두 방법론은 서로 관련되어있다. skip-gram과 negative sampling model은 모두 말뭉치 전체의 동시발생 행렬에 특수한 행렬 분해를 적용한 것과 같다고 한다. 
  • 나아가 GloVE 기법(말뭉치 전체의 통계 정보를 손실 함수에 도입해 미니배치 학습을 진행)이 추론 기반 기법과 통계 기반 기법을 융합하는데 성공하기도 함.

3.6 정리

  • 추론 기반 기법은 추측하는 것이 목적이며, 그 부산물로 단어들의 분산 표현을 얻을 수 있다.
  • word2vec은 추론 기반 기법이며, 단순한 2층 신경망이다.
  • word2vec은 skip-gram과 CBOW 모델을 제공한다.
  • CBOW 모델은 여러 단어(맥락)로부터 하나의 단어(타깃)를 추측한다.
  • 반대로 skip-gram 모델은 하나의 단어(타깃)로부터 다수의 단어(맥락)를 추측한다.
  • word2vec은 가중치를 다시 학습할 수 있으므로, 단어의 분산 표현 갱신이나 새로운 단어 추가를 효율적으로 수행할 수 있다.

번외 : 필요 코드

def remove_duplicate(params, grads):
    '''
    매개변수 배열 중 중복되는 가중치를 하나로 모아
    그 가중치에 대응하는 기울기를 더한다.
    '''
    params, grads = params[:], grads[:]  # copy list

    while True:
        find_flg = False
        L = len(params)

        for i in range(0, L - 1):
            for j in range(i + 1, L):
                # 가중치 공유 시
                if params[i] is params[j]:
                    grads[i] += grads[j]  # 경사를 더함
                    find_flg = True
                    params.pop(j)
                    grads.pop(j)
                # 가중치를 전치행렬로 공유하는 경우(weight tying)
                elif params[i].ndim == 2 and params[j].ndim == 2 and \
                     params[i].T.shape == params[j].shape and np.all(params[i].T == params[j]):
                    grads[i] += grads[j].T
                    find_flg = True
                    params.pop(j)
                    grads.pop(j)

                if find_flg: break
            if find_flg: break

        if not find_flg: break

    return params, grads

def clip_grads(grads, max_norm):
    total_norm = 0
    for grad in grads:
        total_norm += np.sum(grad ** 2)
    total_norm = np.sqrt(total_norm)

    rate = max_norm / (total_norm + 1e-6)
    if rate < 1:
        for grad in grads:
            grad *= rate

class Adam:
    '''
    Adam (http://arxiv.org/abs/1412.6980v8)
    '''
    def __init__(self, lr=0.001, beta1=0.9, beta2=0.999):
        self.lr = lr
        self.beta1 = beta1
        self.beta2 = beta2
        self.iter = 0
        self.m = None
        self.v = None
        
    def update(self, params, grads):
        if self.m is None:
            self.m, self.v = [], []
            for param in params:
                self.m.append(np.zeros_like(param))
                self.v.append(np.zeros_like(param))
        
        self.iter += 1
        lr_t = self.lr * np.sqrt(1.0 - self.beta2**self.iter) / (1.0 - self.beta1**self.iter)

        for i in range(len(params)):
            self.m[i] += (1 - self.beta1) * (grads[i] - self.m[i])
            self.v[i] += (1 - self.beta2) * (grads[i]**2 - self.v[i])
            
            params[i] -= lr_t * self.m[i] / (np.sqrt(self.v[i]) + 1e-7)
import time 
import matplotlib.pyplot as plt

class Trainer:
    def __init__(self, model, optimizer):
        self.model = model
        self.optimizer = optimizer
        self.loss_list = []
        self.eval_interval = None
        self.current_epoch = 0

    def fit(self, x, t, max_epoch=10, batch_size=32, max_grad=None, eval_interval=20):
        data_size = len(x)
        max_iters = data_size // batch_size
        self.eval_interval = eval_interval
        model, optimizer = self.model, self.optimizer
        total_loss = 0
        loss_count = 0

        start_time = time.time()
        for epoch in range(max_epoch):
            # 뒤섞기
            idx = np.random.permutation(np.arange(data_size))
            x = x[idx]
            t = t[idx]

            for iters in range(max_iters):
                batch_x = x[iters*batch_size:(iters+1)*batch_size]
                batch_t = t[iters*batch_size:(iters+1)*batch_size]

                # 기울기 구해 매개변수 갱신
                loss = model.forward(batch_x, batch_t)
                model.backward()
                params, grads = remove_duplicate(model.params, model.grads)  # 공유된 가중치를 하나로 모음
                if max_grad is not None:
                    clip_grads(grads, max_grad)
                optimizer.update(params, grads)
                total_loss += loss
                loss_count += 1

                # 평가
                if (eval_interval is not None) and (iters % eval_interval) == 0:
                    avg_loss = total_loss / loss_count
                    elapsed_time = time.time() - start_time
                    print('| 에폭 %d |  반복 %d / %d | 시간 %d[s] | 손실 %.2f'
                          % (self.current_epoch + 1, iters + 1, max_iters, elapsed_time, avg_loss))
                    self.loss_list.append(float(avg_loss))
                    total_loss, loss_count = 0, 0

            self.current_epoch += 1

    def plot(self, ylim=None):
        x = np.arange(len(self.loss_list))
        if ylim is not None:
            plt.ylim(*ylim)
        plt.plot(x, self.loss_list, label='train')
        plt.xlabel('iterations (x' + str(self.eval_interval) + ')')
        plt.ylabel('loss')
        plt.show()