[Offline 강화학습 챗봇] Policy Gradient를 이용한 구현 도전기 - 강화학습 (1)

2023. 8. 24. 14:49연구 프로젝트/강화학습 챗봇

※어디까지나 도전기일 뿐, 맞는 방법이 아니므로 따라하지 마시오.※

1. 원본 코드

1) 코드 출처

내가 참고하는 논문의 코드가 처음부터 끝까지 다 있다...!

 

GitHub - Ls-Dai/Deep-Reinforcement-Learning-for-Dialogue-Generation-in-PyTorch

Contribute to Ls-Dai/Deep-Reinforcement-Learning-for-Dialogue-Generation-in-PyTorch development by creating an account on GitHub.

github.com

 

 

2) 코드 작동 과정

*코드 알고리즘 작동 과정을 간단하게 시각화

-원 논문에서 사용한 모델: Seq2seq

-모델 학습 과정: 지도 학습을 이용해 모델 첫 학습 -> 강화학습을 이용해 추가적인 학습 진행

-강화학습의 보상 기준 3가지: 답변 난이도, 발화자의 답변 반복성, 전체 대화의 맥락 및 문법

 

 

 

2. 강화학습의 보상 기준 코드 수정

1) 답변의 난이도

*챗봇이 반환하는 질문에 대해 대답하기 쉬운지 측정

-"I don't know"와 같은 dull response를 반환할 확률을 이용해 계산

-S: list of dull responses consisting 8 turns

-NS: cardinality of S

-Ns: the number of tokens in the dull response s

-pseq2seq: likelihood output by Seq2seq models (learned based on the MLE objective)

(+) target S의 길이로 scaling

 

*원본 코드

def ease_of_answering(input_variable, lengths, dull_responses, mask, max_target_len, encoder, decoder, batch_size, teacher_forcing_ratio):
    NS=len(dull_responses)
    r1=0
    for d in dull_responses:
        d, mask, max_target_len = output_var(d, voc)
        newD, newMask = transform_tensor_to_same_shape_as(d, input_variable.size())
        #tar, mask, max_target_len = convertTarget(d)
        forward_loss, forward_len, _ = rl(input_variable, lengths, newD, newMask, max_target_len, encoder, decoder, batch_size, teacher_forcing_ratio)
        # log (1/P(a|s)) = CE  --> log(P(a | s)) = - CE
        if forward_len > 0:
            r1 -= forward_loss / forward_len
    if len(dull_responses) > 0:
        r1 = r1 / NS
    return r1

-dull responses 리스트의 길이(요소 개수)를 NS에 저장 후, dull responses 내 각 response에 대해 반복문 실행

-각 dull response를 입력값으로 하여, input_variable(입력값, 하나의 발화)과 똑같은 크기의 새로운 마스크 텐서 및 dull response 텐서 생성

-input_variable(수식에서 a)이 주어졌을 때, 각각의 dull response(수식에서는 소문자 s)를 반환할 확률에 따른 손실값 계산

 

*수정 코드

dull_responses = ["뭘 말씀하시는지 모르겠어요", "뭘 말하는지 모르겠어", "저는 몰라요", "나는 몰라", "몰라요", "몰라",
                  "뭘 말하는지 모르겠어요", "이해하지 못했어요", "이해하지 못했어", "이해가 안 돼요", "이해가 안 돼",
                  "아무것도 몰라요", "아무것도 몰라", "모르겠어요", "모르겠어", "무슨 말", "무슨 말씀"]

def ease_of_answering(token_ids, mask, labels_ids, dull_responses, forward_model, forward_optimizer, criterion):
  NS = len(dull_responses)
  r1 = 0
  dataset = ChatbotDataset(dull_responses)
  dataloader = DataLoader(train_set, batch_size=32, num_workers=0, shuffle=True, collate_fn=collate_batch)

  for d in dataloader:
    newD, newMask, newLabel = d
    newD = newD.to(device)
    newMask = newMask.to(device)
    newLabel = newLabel.to(device)
    forward_loss, _ = RL(token_ids, newMask, newD, forward_model, forward_optimizer, criterion)
    forward_len = len(newD)
    # log (1/P(a|s)) = CE  --> log(P(a | s)) = - CE
    if forward_len > 0:
      r1 -= forward_loss / forward_len
  if len(dull_responses) > 0:
    r1 = r1 / NS
  return r1

-dull responses를 한국어 리스트로 변경

-dull response 내 답변들 역시 입력값과 똑같은 형태인 ChatbotDataset 클래스 형태로 저장 및 DataLoader 객체 생성

-dataloader에 있는 각 dull response에 대해 입력값과 똑같은 방식으로 마스크 텐서, 정답값 텐서를 구함

(※정답값 텐서: 손실값을 구할 때 쓰일 정답)

-RL 함수를 이용해 손실값 계산 후, 이를 dull response 개수로 나눠 정규화

 

 

 

2) 새로운 정보 반환

*반복되는 대화 및 응답을 피하기 위해 각 에이전트가 매 턴마다 얼마만큼 새로운 정보(발화)를 반환하는가

-hpi, hpi+1, pi, pi+1: 연속된 두 대화 턴을 입력으로 한 인코더 값

 

*원본 코드

def information_flow(responses):
    r2=0
    if(len(responses) > 2):
        #2 representations obtained from the encoder for two consecutive turns pi and pi+1
        h_pi = responses[-3]
        h_pi1 = responses[-1]
        # length of the two vector might not match
        min_length = min(len(h_pi), len(h_pi+1))
        h_pi = h_pi[:min_length]
        h_pi1 = h_pi1[:min_length]
        #cosine similarity
        #cos_sim = 1 - distance.cosine(h_pi, h_pi1)
        cos_sim = 1 - distance.cdist(h_pi.cpu().numpy(), h_pi1.cpu().numpy(), 'cosine')
        #Handle negative cos_sim
        if np.any(cos_sim <= 0):
            r2 = - cos_sim
        else:
            r2 = - np.log(cos_sim)
        r2 = np.mean(r2)
    return r2

-각 에이전트의 발화마다 코사인 유사도 값 계산

-사용자의 발화끼리, 챗봇의 발화끼리, 현재 답변과 이전 턴의 답변에 대해 코사인 유사도값(위 코드에서는 cos_sim)을 구함: : 코사인 유사도값은 -1과 1 사이의 값을 가질 수 있으며, 절대값이 0에 가까울수록 두 요소(벡터)는 유사하지 않고, 절대값이 1에 가까울수록 두 요소는 유사하다고 볼 수 있음. 그러나 -1에 가까워지는 경우는 두 요소의 방향이 다른 경우

-보상 값 = -(negative log of the cosine similarity)

-단, cos_sim 요소 중 0 혹은 음수가 하나라도 있는 경우: 로그 함수는 음수 값을 입력값으로 받을 수 없기에 이런 경우는 로그함수를 적용하지 않고 부호만 변경

-코사인 유사도 값의 절댓값이 커질수록 r2, 보상 값은 작아지는 구조

 

*수정 코드

(지금은 원본 코드와 동일)

 

 

3) 대화 문맥의 일정함

*보상 점수는 높게 책정되나 맥락에 맞지 않거나 문법에 맞지 않는 답변을 반환하는 상황을 막고 답변의 정확성 향상

-pseq2seq(a | qi, pi): 이전 발화 [pi, qi]가 주어졌을 때 답변 a가 반환될 확률

-pbackwardseq2seq(qi | a): 답변 a가 주어졌을 때 qi가 반환될 backward 확률

 

*원본 코드

def semantic_coherence(input_variable, lengths, target_variable, mask, max_target_len, forward_encoder, forward_decoder, backward_encoder, backward_decoder, batch_size, teacher_forcing_ratio):
    #print("semantic_coherence-IN R3:")
    #print("semantic_coherence-Input_variable.shape :", input_variable.shape)
    #print("semantic_coherence-Lengths.shape :", lengths.shape)
    #print("semantic_coherence-Target_variable.shape :", target_variable.shape)
    #print("semantic_coherence-Mask.shape :", mask.shape)
    #print("semantic_coherence-Max_Target_Len :", max_target_len)
    r3 = 0
    forward_loss, forward_len, _ = rl(input_variable, lengths, target_variable, mask, max_target_len, forward_encoder, forward_decoder, batch_size, teacher_forcing_ratio)
    ep_input, lengths_trans = convert_response(target_variable, batch_size)
    #print("semantic_coherence-ep_input.shape :", ep_input.shape)
    #print("semantic_coherenceLengths transformed.shape :", lengths_trans.shape)
    ep_target, mask_trans, max_target_len_trans = convert_target(target_variable, batch_size)
    #print("semantic_coherence-ep_target.shsape :", ep_target.shape)
    #print("semantic_coherence-mask transformed.shape :", mask_trans.shape)
    #print("semantic_coherence-max_target_len_trans.shape :", max_target_len_trans)
    backward_loss, backward_len, _ = rl(ep_input, lengths_trans, ep_target, mask_trans, max_target_len_trans, backward_encoder, backward_decoder, batch_size, teacher_forcing_ratio)
    if forward_len > 0:
        r3 += forward_loss / forward_len
    if backward_len > 0:
        r3+= backward_loss / backward_len
    return r3

-rl 함수(이는 다음 글에서 설명): 간단하게만 적으면...손실값 계산 및 챗봇이 생성한 답변을 반환하는 함수

-input_variable: 입력값, target_variable: 출력값

-input_variable이 주어졌을 때 target_variable이 나올 확률로 손실 값 계산 → forward loss

-target_variable이 주어졌을 때 input_variable이 나올 확률로 손실 값 계산 → backward loss

-backward_loss를 구할 때 mask 텐서를 그에 맞게 변형

 

*수정 코드

def semantic_coherence(token_ids, mask, labels_ids, dull_responses, forward_model, forward_optimizer, backward_model, backward_optimizer, criterion, max_len=64):
  r3 = 0
  forward_loss, _ = RL(token_ids, mask, labels_ids, forward_model, forward_optimizer, criterion)
  #need mask of labels_ids
  backward_loss, _ = RL(labels_ids, mask, token_ids, forward_model, forward_optimizer, criterion)
  mask = [0] * len(labels_ids) + [1] * len(token_ids) + [0] * (max_len - len(labels_ids) - len(token_ids))
  mask = np.array(mask)

  if len(token_ids) > 0:
    r3 += forward_loss / len(token_ids)
  if len(labels_ids) > 0:
    r3 += backward_loss / len(labels_ids)
  return r3

-forward loss 계산, labels_ids의 mask 텐서를 생성 및 backward loss 계산

(하지만 이러면 안 되었다...왜냐하면 내가 갖고 있는 코드로는 token_ids와 labels_ids 모두 이미 패딩을 완료한 상태이기에 내가 지정한 최대 길이를 갖고 있기 때문이다. 그렇기에 mask 텐서를 저렇게 구해서도 안 된다. 그러나 이때는 아직 코드에 대한 완전한 이해가 되지 않은 상태였기에 그냥 넘어갔다...)

 

 

4) 강화학습의 최종 보상

1 + λ2 + λ3 = 1이 되도록 설정

-참고 논문에서는 각각 0.25, 0.25, 0.5로 설정

 

*보상 계산 함수 원본 코드

def calculate_rewards(input_var, lengths, target_var, mask, max_target_len, forward_encoder, forward_decoder, backward_encoder, backward_decoder, batch_size, teacher_forcing_ratio):
    #rewards per episode
    ep_rewards = []
    #indice of current episode
    ep_num = 1
    #list of responses
    responses = []
    #input of current episode
    ep_input = input_var
    #target of current episode
    ep_target = target_var

    #ep_num bounded -> to redefine (MEDIUM POST)
    while (ep_num <= 10):

        print(ep_num)
        #generate current response with the forward model
        _, _, curr_response = rl(ep_input, lengths, ep_target, mask, max_target_len, forward_encoder, forward_decoder, batch_size, teacher_forcing_ratio)

        #Break if :
        # 1 -> dull response
        # 2 -> response is less than MIN_LENGTH
        # 3 -> repetition ie curr_response in responses
        if(len(curr_response) < MIN_COUNT):# or (curr_response in dull_responses) or (curr_response in responses)):
            break

        #Ease of answering
        r1 = ease_of_answering(ep_input, lengths, dull_responses, mask, max_target_len, forward_encoder, forward_decoder, batch_size, teacher_forcing_ratio)

        #Information flow
        r2 = information_flow(responses)

        #Semantic coherence
        r3 = semantic_coherence(ep_input, lengths, target_var, mask, max_target_len, forward_encoder, forward_decoder, backward_encoder, backward_decoder, batch_size, teacher_forcing_ratio)

        #Final reward as a weighted sum of rewards
        r = l1*r1 + l2*r2 + l3*r3

        #Add the current reward to the list
        ep_rewards.append(r.detach().cpu().numpy())

        #We can add the response to responses list
        curr_response, lengths = convert_response(curr_response, batch_size)
        curr_response = curr_response.to(device)
        responses.append(curr_response)

        #Next input is the current response
        ep_input = curr_response
        #Next target -> dummy
        ep_target = torch.zeros(MAX_LENGTH,batch_size,dtype=torch.int64)
        #ep_target = torch.LongTensor(torch.LongTensor([0] * MAX_LENGTH)).view(-1, 1)
        ep_target = ep_target.to(device)

        #Turn off the teacher forcing  after first iteration -> dummy target
        teacher_forcing_ratio = 0
        ep_num +=1

    #Take the mean of the episodic rewards
    return np.mean(ep_rewards) if len(ep_rewards) > 0 else 0

-총 10번의 에피소드를 반복

-1번의 에피소드마다 rl 함수를 호출해 Seq2seq 모델로 문장 생성. 단, 해당 문장의 길이가 따로 지정한 최소 길이보다 작으면 중단

-가중합으로 보상 값을 계산한 후, 다음 반복의 입력값을 현재 출력값으로 변경 (이렇게 되면 챗봇은 최대 10번, 자기 스스로와 대화를 진행하게 된다(...))

 

*수정 코드

(지금은 원본 코드와 동일)