Solution ที่ชนะการแข่งขัน Super AI SCG Thai QA

Chomtana Chanjaraswichai
Super AI Engineer
Published in
4 min readJan 24, 2021
https://www.kaggle.com/c/super-ai-engineer-scg-thai-qa-individual/leaderboard
คะแนน Exact match และ F1 ของ Top 10

เรามาดูกันเลยว่าเราจะต้องทำยังไงเอให้โมเดลมีประสิทธิภาพมากขึ้นกว่าเดิม!

จาก Leaderboard ข้างบนจะเห็นว่า 22p21c0642 (ผมเอง) ได้ Rank 1 ทั้งในเชิง Exact match และ F1 score

Super AI SCG Thai QA เป็นการแข่งขันสร้าง AI เพื่อตอบคำถามจากบทความที่ให้ (มีทั้งแบบสั้นซึ่งเป็น Facebook post และแบบยาวซึ่งเป็นบทความคล้ายๆ Medium) ในคำถามและคำตอบเป็นภาษาไทย มีชื่อเฉพาะ และมีการหา Hashtag

โดยระยะเวลานั้นสั้นมาก แค่ 1 สัปดาห์

แน่นอนว่าถ้า Train model ใหญ่ๆ ด้วย Dataset size big ไม่เสร็จแน่นอน

Kaggle notebook (ต้อง run ใน colab ถ้า tqdm ยังมีปัญหาอยู่)

ถ้าเปิดดูใน link จะเห็นว่ามีปัญหาที่ tqdm อยู่ทำให้รันไม่ได้ เนื่องจาก notebook นี้ถูกเขียนขึ้นที่ colab จึงควรนำไปรันใน colab จึงจะเกิดปัญหาน้อยที่สุด

QA Model Ranking ในปัจจุบัน

Ranking ใน dataset SQuAD1.1
Ranking ใน dataset SQuAD2.0

จาก Ranking ใน dataset SQuAD1.1 และ SQuAD2.0 จะเห็นว่า Model ที่แก้ปัญหานี้ได้ดีๆจะเป็น XLNet และ SA-Net

Model ที่เราเลือกใช้

Model ที่เราเลือกใช้นั้นได้แก่ xlm-roberta-large-squad2

https://huggingface.co/deepset/xlm-roberta-large-squad2

ที่เราเลือกใช้โมเดลนี้เนื่องจากเป็น Model ที่ ได้รับการ Pretrain ทั้งภาษาไทย และภาษาอังกฤษ และเมื่อเราลองนำโมเดลนี้มาทดสอบแล้วก็พบว่าสามารถทำนายได้ดีมาก คำตอบก็ออกมาดูมีความหมายดี

ส่วนโมเดลที่ได้ผลลัพธ์ดีสุดใน Dataset SQuAD 1.1 และ 2.0 นั้น ไม่มี Model ที่ Pretrain มาสำหรับภาษาไทย

แล้วทำไมไม่ Train เองหล่ะ??? เนื่องจากการแข่งขันนี้ระยะเวลานั้นสั้นมาก แค่ 1 สัปดาห์ แน่นอนว่าถ้า Train model ใหญ่ๆ ด้วย Dataset size big ไม่เสร็จแน่นอน ถ้าหากลดขนาดของ model หรือขนาดของ dataset ก็จะได้ผลลัพธ์ที่แย่กว่าการใช้ Pretrained model มากๆๆๆๆ

แต่ถึงยังไงเราก็ได้ลอง fine tune model monsoon-nlp/bert-base-thai มาด้วย แต่ยังไม่ได้ลองนำไปใช้งาน

การนำโมเดลไปใช้

การนำโมเดลไปใช้งานมีด้วยกันหลักๆ 4 วิธี (ในความจริงมีนับไม่ถ้วน แต่ตัดมาแค่ที่หลักๆ) ซึ่งสามารถดูได้จากเว็บต้นฉบับ

  • ใช้ transformers AutoTokenizer, AutoModelForQuestionAnswering
from transformers import AutoModelForQuestionAnswering, AutoTokenizer, pipelinemodel_name = "deepset/xlm-roberta-large-squad2"

# a) Get predictions
nlp = pipeline('question-answering', model=model_name, tokenizer=model_name)
QA_input = {
'question': 'Why is model conversion important?',
'context': 'The option to convert models between FARM and transformers gives freedom to the user and let people easily switch between frameworks.'
}
res = nlp(QA_input)

# b) Load model & tokenizer
model = AutoModelForQuestionAnswering.from_pretrained(model_name)
tokenizer = AutoTokenizer.from_pretrained(model_name)
  • ใช้ FARM
from farm.modeling.adaptive_model import AdaptiveModel
from farm.modeling.tokenization import Tokenizer
from farm.infer import QAInferencer

model_name = "deepset/xlm-roberta-large-squad2"

# a) Get predictions
nlp = QAInferencer.load(model_name)
QA_input = [{"questions": ["Why is model conversion important?"],
"text": "The option to convert models between FARM and transformers gives freedom to the user and let people easily switch between frameworks."}]
res = nlp.inference_from_dicts(dicts=QA_input, rest_api_schema=True)

# b) Load model & tokenizer
model = AdaptiveModel.convert_from_transformers(model_name, device="cpu", task_type="question_answering")
tokenizer = Tokenizer.load(model_name)
  • ใช้ haystack
reader = FARMReader(model_name_or_path="deepset/xlm-roberta-large-squad2")
# or
reader = TransformersReader(model="deepset/xlm-roberta-large-squad2",tokenizer="deepset/xlm-roberta-large-squad2")

ซึ่งผมนั้นเลือกใช้วิธี transformers AutoTokenizer, AutoModelForQuestionAnswering เนื่องจากตอบออกมาเป็น score อย่างละเอียด (แบบ low level) จึงสามารถนำไปทำ customization ตามขั้นตอนข้างล่างได้อย่างอิสระ

แต่การใช้งานโมเดลนั้น ในขั้นตอน Tokenize จะต้องเพิ่ม Parameter พิเศษ เข้าไปในตัวอย่างก่อน เพื่อให้การ Token นั้นไม่ลบ เครื่องหมายที่บอก segment (<s>) ด้วยการเพิ่ม add_special_tokens=True, return_tensors="pt" ดัง Code ต่อไปนี้

def predict:
pretrained = "deepset/xlm-roberta-large-squad2"
tokenizer = AutoTokenizer.from_pretrained(pretrained)
model = AutoModelForQuestionAnswering.from_pretrained(pretrained)
inputs = tokenizer(question, context, add_special_tokens=True, return_tensors="pt")
input_ids = inputs["input_ids"].tolist()[0]
model_ans = model(**inputs)
return model_ans, input_ids

ลักษณะของ Output จากวิธี transformers AutoTokenizer, AutoModelForQuestionAnswering

Output ของวิธีดังกล่าวจะได้ออกมาเป็น pytorch tensor ของคะแนนของจุดเริ่ม (model_ans.start_logits) และจุดสิ้นสุดของคำตอบ (model_ans.end_logits) มีลักษณะดังนี้

model_ans.start_logits = [-1.3, -1.28, 0.13, -2.11, 0.08, -0.11]
model_ans.end_logits = [-0.5, -1.98, -2.1, -0.11, 0.08, -0.02]
หมายความว่า
เริ่ม ที่ token (คำ) ที่ 3 เนื่องจาก res.start_logits ของคำที่ 3 มีค่ามากที่สุด
จบ ที่ token (คำ) ที่ 5 เนื่องจาก res.end_logits ของคำที่ 6 มีค่ามากที่สุด

ถ้าใครงงว่า pytorch tensor คืออะไร ถ้าเคยใช้ numpy มามันก็คล้ายๆกับ numpy array แต่เป็นของ pytorch ซึ่งสามารถแปลงกลับเป็น numpy array ได้ (แต่ต้องนำค่ากลับมายัง CPU ก่อน) หรือจะใช้ function ของ pytorch ในการ manipulate pytorch tensor โดยตรงเลยก็ได้

การแปลง Output ของ Model ให้เป็นคำตอบที่เป็นข้อความ

โปรดสังเกตว่าใน code ด้านบนในส่วนของการเรียกใช้ Model จะมีบรรทัดที่เขียนว่า

input_ids = inputs["input_ids"].tolist()[0]

ซึ่งบรรทัดดังกล่าวจะต้องนำมาใช้ในการแปลง Output ของ Model ให้เป็นคำตอบที่เป็นข้อความ

โดยการแปลงนั้นจะใช้ function tokenizer.convert_tokens_to_string

สามารถทำได้ดัง code ต่อไปนี้

def get_answer_from_prediction:
model_ans, input_ids = predict(...)

answer_start_scores = model_ans.start_logits.detach().numpy()[0]
answer_end_scores = model_ans.end_logits.detach().numpy()[0]
answer_score = np.max(answer_start_scores) + np.max(answer_end_scores)
answer = tokenizer.convert_tokens_to_string(tokenizer.convert_ids_to_tokens(input_ids[answer_start:answer_end]))

ทำไมต้องแปลง pytorch tensor เป็น numpy

WARNING: การกระทำดังกล่าวเป็น Anti-pattern ไม่ควรนำมาใช้ในการทำงานจริง

ในที่นี้จะเห็นว่ามีการแปลงเป็น numpy เนื่องจากขั้นตอนต่อไปจะต้องทำการ manipulate output tensor ซึ่งผมถนัดในการใช้ numpy ในการทำมากกว่า แต่ก็มีข้อเสียคือไม่สามารถใช้ GPU / TPU ได้ (แต่ผมลองทำโดยใช้ pytorch ล้วนๆ 100% ใน TPU แล้วประสิทธิภาพแย่มาก น่าจะเป็นเพราะบาง operation ไม่รองรับใน TPU จึงต้องเสียเวลาเยอะมากในการดึงค่าจาก TPU มาใส่ใน CPU) แต่ใน GPU พอว่าเกิด Error ตั้งแต่ขั้นตอนการรัน model จึงไม่ได้ทำต่อ พร้อมกับใน CPU ก็ไม่ได้ใช้เวลารันนานมากจึงใช้ CPU ในการรัน

แต่ถ้าจะทำเพื่อใช้ใน production จริงก็ควรจะใช้ pytorch function ในการ manipulate pytorch tensor โดยตรง เพื่อให้ได้ประสิทธิภาพสูงที่สุด และเป็น Best practice

ปัญหาที่พบเจอจากการใช้โมเดลตรงๆ

  • ไม่ตอบอะไรสักอย่าง (เราใช้สัญลักษณ์ <UNK/> แทนเหตุการณ์นี้)
  • ตอบเครื่องหมายแบ่งประโยค (<s>)
  • raise Exception ซึ่งอ่านเข้าใจยาก แต่ถ้าลองเอาไปหาใน google แล้วทำการวิเคราะห์ดูจะพบว่าเป็น Error เกี่ยวกับ context หรือ question มีขนาดยาวเกินไป

การแก้ปัญหาไม่ตอบอะไรสักอย่าง หรือตอบ <s>

การแก้ปัญหาที่โมเดลไม่ตอบอะไรสักอย่าง หรือตอบ <s> นั้นสามารถทำได้ดัง pseudocode ดังนี้

while model not answer or answer <s>:
try next answer

แน่นอนว่าการตรวจสอบว่า model not answer or answer <s> นั้นสามารถทำได้ง่ายมากในสายตาของคนที่รู้วิธีการเขียนโปรแกรมพื้นฐาน

แต่ก็ไม่ใช่ทุกคนที่จะรู้วิธีการ try next answer ดังนั้นเรามาดูกันเลยว่าการ try next answer นั้นทำยังไง ซึ่งก็จะทำได้ตาม pseudocode ดังต่อไปนี้

def try_next_answer:
delete old answer from prediction
get new answer from prediction (after delete old answer)

ต่อมาเราก็จะมาดูกันว่า มันจะลบออกไปจาก prediction ได้ยังไง วิธีการในที่นี้จะขึ้นอยู่กับวิธีการ implement ที่เลือกใช้ ในที่นี้จะใช้วิธีการที่ผมเลือกใช้ซึ่งก็คือ การใช้ transformers AutoTokenizer, AutoModelForQuestionAnswering

จากที่กล่าวมาข้างต้นว่ามันจะได้คะแนนของจุดเริ่ม (model_ans.start_logits) และจุดสิ้นสุดของคำตอบ (model_ans.end_logits) ในการ delete old answer from prediction เราจะทำการ set model_ans.start_logits และ model_ans.end_logits ของตำแหน่งที่เป็นคำตอบเก่า ให้เป็น -inf

def delete_old_answer_from_prediction:
model_ans.start_logits[ argmax(model_ans.start_logits) ] = -inf
model_ans.end_logits[ argmax(model_ans.end_logits) ] = -inf

ส่วนการ get new answer from prediction (after delete old answer) เราจะทำการหาคำตอบใหม่ข้างต้นตาม pseudocode ของ get_answer_from_prediction ที่ได้กล่าวไปแล้วข้างต้น

def get_new_answer_from_prediction:
get_answer_from_prediction(...)

การจัดการกับข้อความที่ยาวๆ

ในโจทย์ข้อนี้เราได้สังเกตเห็นว่า คำตอบและคำถาม มักจะอยู่ในบรรทัดเดียวกัน สำหรับข้อความที่ยาวๆ

ดังนั้นวิธีที่ใช้ก็คือจะทำการค่อยๆนำแต่ละบรรทัดเข้าไปใน BERT เรื่อยๆ

แล้วก็จะได้คำตอบของแต่ละบรรทัดออกมา พร้อมกับคะแนนของแต่ละคำตอบ ซึ่งสามารถบอกได้ด้วย max(start_logits) + max(end_logits)

แล้วก็ทำการเลือกเฉพาะคำตอบที่ได้คะแนนมากที่สุด มาใช้เป็นคำตอบสุดท้าย

คนที่อ่านถึงตรงนี้ก็คงมีคำถามว่า แล้วถ้าตัดแบบทีละ 2 บรรทัด หรือตัดมาจนกว่ามันจะเต็มความจุของ transformer มันจะดีกว่าหรือเปล่า เรื่องนี้ทางผู้เขียนได้ทำการทดลองมาแล้ว พบว่าผลลัพธ์ออกมาแย่กว่าการตัดทีละบรรทัด สำหรับเหตุผลก็น่าจะเป็นที่คำถาม และ คำตอบ มักจะอยู่ในบรรทัดเดียวกัน การช่วย guide model ว่าให้มองแค่ในบรรทัดเดียวกัน จึงช่วยเพิ่ม performance ให้กับ model

จากที่กล่าวมาสามารถเขียนเป็น pseudocode ได้ดังนี้

lines = context.split("\n") # ตัดแบ่งเป็นบรรทัดๆ
for line in lines:
result, score = predict(line)
เก็บ result และ score ไว้
return result ของประโยคที่มี score มากที่สุด

ทั้งนี้ก็มีผู้ที่เขียน Blog เกี่ยวกับปัญหานี้โดยเฉพาะ และมีท่าที่หลากหลายในการแก้ปัญหา

--

--