728x90
반응형

👉이전글에서 예선에 대한 내용을 확인할 수 있다.
9월13일 본선OT 이후로 KT AI해커톤 본선이 시작됐다.

본선

본선 과제는 KT DX플랫폼(RPADU, APPDU)과 GPT, Bard와 같은 외부망의 생성형AI 모델에 proxy로 접근할 수 있게 해주는 gen.AI플랫폼을 이용하여
업무에 활용할 수 있는 서비스를 개발하는 것이였다.
즉, RPADU를 활용하여 데이터를 수집하고, APPDU를 활용하여 backend서버를 개발하고 View를 제공해야하는데, 데이터 분석이나 학습에 활용할 AI모델은 gen.AI플랫폼을 활용하라는 것이다.

그리고 본선이 진행되는 10/19(목) 이전까지 사전 개발기간이 주어진다.
본선이 진행되는 10/19~20 1박2일 동안은 실무평가, 임원평가 총 두 번의 평가가 진행되며, 해당 평가에서 좋은 성적을 거두어야 수상을 할 수 있게 된다.

사전학습

나는 과제발굴에 앞서 LLM이 뭔지 파악할 필요가 있다고 생각했다.
LLM은 Large Language Model의 약자로 사용자 질의에 따른 응답을 사람처럼 해주는 모델이다. 많이들 써봤을 ChatGPT 그 자체이다.
ChatGPT와 같은 LLM모델을 우리의 비즈니스에 맞게 응답해줄 수 있도록 할 수 있다.
예를들어, OSS가 뭐야?라고 ChatGPT에 질문을 했을 때, Open Source Software라는 대답을 준다.
여기서 ChatGPT를 우리 비즈니스(OSS시스템)에 맞게 학습을 시키면, Operating Support System이라는 답변을 줄 수 있도록 할 수 있다.

ChatGPT와 같은 LLM모델을 학습시키는 방법은 대표적으로 fine-tuning(파인튜닝)기법이 있는데,
LLM모델에 추가 데이터를 학습시켜 우리 비즈니스에 특화된 모델을 만드는 것이다.
하지만 이 방법은 들어가는 비용대비 얻을 수 있는 효과는 미미하다. 그래서 대규모의 플랫폼을 만드는 것이 아니라면 추천하지 않는 방법이다.

대안으로 Embedding(임베딩)기술을 응용한 Retrieval-Augmented Generation(RAG)방식이 있다.
RAG방식은 먼저 임베딩 과정을 거쳐 벡터로 만들어진 우리 비즈니스 데이터를 별도 저장소에 저장한다.
그리고 사용자가 질의를 하면 해당 질의와 유사한 비즈니스 데이터를 별도 저장소에서 가져오고,
가져온 비즈니스 데이터를 사용자 질의와 합쳐서 프롬프트 형태로 구성한 후에 LLM모델에 질의를 날린다. 이 방식이 RAG방식이다.

이 때, 활용되는 Embedding(임베딩)은 자연어를 모델이 이해할 수 있는 벡터형태로 변환하는 기술인데,
RAG방식에서 사용자 질의와 유사한 데이터를 찾을 때에도 벡터 간의 유사도를 계산하기 때문에 임베딩 기술이 유용하게 쓰인다.

Embedding(임베딩)에 관한 자세한 내용은 👉여기에서 확인할 수 있다.

과제선정

과제선정부터 어려웠다.
과제 선정을 위해 팀내 Confluence플랫폼을 활용하여 아이디어를 수렴했다.
팀장님께서 본선부터는 팀내 다른 사원들도 같이 간접참여할 수 있는 형태면 좋을 것 같다고 말씀하셔서,
해커톤 팀원들 뿐만 아니라 다른 사원들의 아이디어도 같이 수렴했다.

여러 아이디어가 많이 나왔지만...
우리가 잘할 수 있고, 정책이나 데이터 활용상 제약이 없는 OSS Assistant 아이디어로 선정했다.

OSS Assistant는 현장작업자의 VOC를 줄이고 빠른 정보검색 및 신규인력의 빠른 적응을 위한 OSS도메인 특화 챗봇이다.
(역시 LLM모델엔 챗봇이 가장 만만하다..)

대표적인 두 가지 기능으로 여러데이터를 기반으로 응답을 주는 RAG기반 챗봇기능과
사용자 질의에 따라 OSS의 오더라는 것을 작업자가 직접 처리할 수 있게 하는 기능을 구현하기로 했다.
그리고 나는 RAG기반 챗봇기능 구현을 담당하였다.

구현과정

초기설계

내가 담당한 RAG기반 챗봇기능을 구현하기 위해 설계를 진행했다.
초기설계는 챗봇의 서버로 활용될 APPDU서버가 다음 작업을 모두 수행하는 것으로 설계했다.

  1. KMS(사내 Jira Confluence플랫폼) 데이터 로딩
    : KMS라는 사내 Jira Confluence플랫폼에는 업무에 필요한 SOP나 가이드 자료 등을 업로드하여 공유할 수 있는 플랫폼이다. 해당 플랫폼의 데이터를 챗봇에 활용하기 위해 KMS서버에 데이터를 요청해서 받아야한다.
  2. RPADU(사내 RPA tool)의 파싱데이터 수신
    : AI해커톤 본선의 평가요소 중 RPADU라는 사내 RPA tool 활용 항목이 있다. 이를 충족시키기 위해 우리 시스템의 고객들에게 매월 공유하고 있는 ppt형태의 사용자매뉴얼 데이터를 RPADU를 활용하여 파싱할 수 있도록 RPADU개발을 진행했다.
    해당 툴은 코딩이 아닌 GUI로 개발할 수 있도록 만들어진 프로그램이고, C#기반의 프로그램이다.
  3. 데이터전처리 및 임베딩 수행
    : 챗봇에 활용될 데이터의 품질을 높이기 위해서는 적절한 전처리 작업이 필요하다. 그리고 전처리가 완료된 데이터들을 임베딩하여 LLM모델이 이해할 수 있는 벡터형태로 변환해야 한다.
  4. Vector DB 임베딩 벡터 저장
    : Vector Database는 임베딩된 벡터를 저장하고 조회하는데 특화된 데이터베이스이다. 벡터에 메타데이터 형태로 관련 정보를 매핑하여 저장할 수 있고, 사용자 질의와 유사한 정보를 보다 쉽게 조회할 수 있다. 임베딩된 벡터를 해당 Vector DB에 저장하는 작업이 필요하다.

환경세팅

개발을 위한 개발환경을 세팅하고 APPDU서버환경을 세팅하는 작업을 진행했다. APPDU서버환경은 서버간 방화벽 작업을 진행해야 했다.
그 중, KMS서버-APPDU서버간 방화벽 작업은 각 서버 담당자 문의 결과, 지원하지 않는 연동으로 방화벽해제가 어렵다는 회신을 받았다.
KMS데이터를 활용하지 않는 방향까지 고려를 했지만, 해당 애로사항을 해커톤 팀내 공유하고 회의를 진행한 결과 KMS데이터를 활용할 새로운 아이디어가 고안될 수 있었다.

2차 설계

초기설계에서 KMS데이터는 내 로컬PC에서 로드하여 APPDU서버로 전달하는 방향으로 2차 설계를 확정지었다.
KMS담당자로서 KMS데이터 학습을 요청받거나 필요할 때, 해당 데이터들을 검수하고 학습에 적절한 데이터라고 판단되면
사전에 개발한 KMS데이터 로딩 프로그램을 실행시켜 APPDU서버에 KMS데이터를 전달할 것이다.

개발

KMS Loader

학습이 진행될 KMS데이터는 oss2chatbot라벨이 붙은 Confluence 페이지이다. 해당 라벨은 설정하기 나름이다.
그리고 KMS데이터는 LangchainConfluenceLoader라이브러리를 활용하여 가져온다.

from langchain.document_loaders import ConfluenceLoader
from flask import current_app, jsonify
import requests

class KmsService:
    def __init__(self, config):
        self.config = config

    def load_kms_sop(self):
        # confluence loader 객체 생성
        loader = ConfluenceLoader(
            url=current_app.config['KMS_URL'],
            username=current_app.config['KMS_USERNAME'],
            api_key=current_app.config['KMS_PASSWORD'],
        )
        # kms데이터 로드 (라벨 설정)
        kms_data = loader.load(label="oss2chatbot")
        kms_documents = []

        # 제목 + 내용
        for data in kms_data:
            temp = data.metadata['title'] + " : " + data.page_content
            kms_documents.append(temp)

         request_data = {'data': kms_documents}
         response = requests.post(APPDU_SERVER_URL, json=request_data)

         return response

위 코드는 특정 라벨이 붙은 Confluence 페이지를 가져와서 각 페이지의 데이터마다 제목과 내용을 병합해주고 APPDU서버로 데이터를 전송하는 코드이다.

Manual Parsing RPA

Manual PPT 데이터를 파싱하는 RPA tool을 활용하면 위 사진에서 보이는 것처럼 코드가 아닌 GUI형태로 프로그램을 개발할 수 있다.
프로그램 소스 전체가 사진에 표현되어 있진 않지만, flow를 간략히 설명하면,

  1. 파싱할 PPT파일이 있는 디렉토리에서 파일 list를 읽는다.
  2. 각 파일 list를 for loop 모듈을 활용하여 읽는다.
  3. 각 파일에서 파싱된 데이터들을 활용하여 json 형태로 문자열을 만들어준다. (APPDU서버로 HTTP POST요청을 통해 body에 json형태로 실어보내주기 위함)
  4. 만들어진 json형태의 문자열에서 Encoding 오류나는 문자를 찾아 수정해준다. (if 모듈 하드코딩)
  5. json형태로 만들어진 문자열을 body에 실어 APPDU서버로 HTTP POST 요청을 보낸다.

위 flow를 모두 거치면, 파싱 대상 PPT파일의 데이터가 APPDU서버에 전송된다.

Embedding

import os, re
import openai
from langchain.text_splitter import RecursiveCharacterTextSplitter
from flask import current_app, jsonify

def preprocessing_text(document, sep_token = " \n "):
    preprocessing_document = []
    for doc in document:
        doc.page_content = doc.page_content.lower() # 소문자 통일
        doc.page_content = doc.page_content.replace("\n", " ")    # 개행 -> 공백문자
        doc.page_content = doc.page_content.replace("\t", " ")    # White space 통일
        doc.page_content = re.sub('[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]a-z]{2,}', 'email_address', doc.page_content) # email 주소 치환
        doc.page_content = re.sub('[A-Za-z0-9가-힣\s]', ' ', doc.page_content) # 특수문자 제거
        doc.page_content = re.sub(r"\s+", " ", doc.page_content)
        preprocessing_document.append(doc)

    return preprocessing_document

def split_chunk(document, size = 1000, overlap=200):
    text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
    documents = text_splitter.split_documents(document)
    return documents

def get_embedding(text, engine="text-embedding-ada-002"):
    openai.api_type = current_app.config('AZURE_OPENAI_API_TYPE']
    openai.api_base = current_app.config('AZURE_OPENAI_API_BASE']
    openai.api_version = current_app.config('AZURE_OPENAI_API_VERSION']
    openai.api_key = current_app.config('AZURE_OPENAI_API_KEY']
    text = text.replace("\n", " ")

    return openai.Embedding.create(input=[text], engine=engine)["data"][0]["embedding"]

def documents_embedding(input_documents, file_name):
    current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')

    ids = []
    doc_metas = []
    documents = []
    embedding_docs = []

    for idx, document in enumerate(input_documents):
        embedding_doc = get_embedding(document.page_content)
        meta = {
            "source": file_name,
            "updated_time": current_time
        }
        ids.append(idx)
        doc_metas.append(meta)
        documents.append(document.page_content)
        embedding_docs.append(embedding_doc)

    embeded_document = {
        'ids': ids,
        'doc_metas': doc_metas,
        'documents': documents,
        'embedding_docs': embedding_docs
    }
    return embeded_document

전처리 코드는 데이터에 따라 상이하여 공통부분만 작성하였다.
Langchain.ConfluenceLoader로 데이터를 가져오든, RPA로 데이터를 가져오든 위 공통함수들을 모두 거치게 된다.

  1. 받은 데이터는 먼저 preprocessing_text함수에서 전처리가 진행된다. 챗봇의 정확도를 위한 작업이고, 간단하게 소문자 통일, 특수문자 및 White space 제거 작업이 진행된다.
  2. 전처리된 데이터들은 1000자씩 앞뒤로 200자가 중첩되게 하나의 chunk로서 분리된다. 이또한 챗봇의 정확도를 향상시키기 위함이다.
  3. 각각의 chunk로 분리된 데이터들은 openai.Embedding api를 활용하여 임베딩이 진행된다. 임베딩 작업을 통해 벡터로 변환된 데이터들은 메타 데이터로 원본 데이터와 id값, file name 등을 매핑하여 ChromaDB라는 Vector DB에 저장된다.

하지만 Vector DB로 ChromaDB를 활용하는데 어려움이 있었다.
사용중인 Python 3.8 환경에서 버전문제가 발생하였다. Sqlite3 버전이 낮아서 문제였고, 버전을 올려야 했다.
버전 올리는 것 또한 local의 window환경과 실서버의 linux환경에서의 방법이 상이했다.
window 환경에서는 Sqlite3의 dll파일만 교체해주면 되었지만, linux환경에서는 pysqlite3-binary를 설치해야 했다.

그래서 사외망 환경에서 whl파일을 사내망으로 가져와서 프로젝트 root경로에 놓고, Dockerfile에 pip install 명령어를 추가하여 가져온 whl파일을 설치하도록 했다. 그랬더니 비로소 서버환경에서의 ChromaDB가 설치되었다.
그 후, pysqlite3-binary 라이브러리를 사내 라이브러리 저장소인 nexus라는 저장소에 저장하도록 요청하여 별도 Dockerfile에 명령어를 입력하지 않아도 되도록 했다.

프롬프트 엔지니어링

from flask import current_app, jsonify
from .file_manager import get_dict_for_collection, get_embedding
from views.order.order_analysis import *
import openai, copy

# 프롬프트 템플릿 생성
def make_template(documents, filename):
    result_documents = ""
    for doc in documents:
        result_documents = result_documents + " " + doc
    template = """You are the best assistant that describes many knowledges in Korean.
    당신은 문의에 대한 방법을 제공하는 것이 목표입니다.
    인사를 할 경우에 '안녕하세요. 무엇을 도와드릴까요?라고 답변해주세요.
    그리고 답변 마지막에 '자세한 내용은 {filename}을 참고해주세요.'라는 메세지를 남겨주세요.
    아래 내용을 참고하여 답변해주세요. <내용>{documents}<내용끝>
    1. 위 내용 전부를 읽기 쉽도록 단계별로 재작성 해주세요.
    2. 불필요한 개행문자는 제거해주세요.
    3. 답변은 1000자 이내로 작성해주세요.""".format(documents=result_documents, filename=filename)

    return template

# Function Call용 템플릿 생성
def make_order_template():
    template = """You are the best assistant that describes many knowledges in Korean."""
    return template

# 챗봇 main 함수
def basic_rag_chat_completion(messages, rag):
    # function call을 위한 메세지 백업
    func_messages = copy.deepcopy(messages['messages'])

    # openai config
    openai.api_type = current_app.config['AZURE_OPENAI_API_TYPE']
    openai.api_base = current_app.config('AZURE_OPENAI_API_BASE']
    openai.api_version = current_app.config('AZURE_OPENAI_API_VERSION']
    openai.api_key = current_app.config('AZURE_OPENAI_API_KEY']

    # 사용자 질의 임베딩 및 Chroma DB에서 유사 documents 조회
    user_input = messages['messages'][-1]['content']
    user_embedding = get_embedding(user_input)
    chroma_response = rag.query(
        query_embeddings=user_embedding,
        n_results=3
    )

    # system message 세팅
    template_message = make_template(chroma_response['documents'][0], chroma_response['metadatas'][0][0]['source'])
    system_message = {"role": "system", "content": template_message}
    messages['messages'].insert(0, system_message)

    # 질의
    response = openai.ChatCompletion.create(
        engine="gpt-4",
        messages=messages['messages'],
        functions=get_functions(),
        temperature=0.0,        # 모델의 정확성, 0과 1사이로 조절하여 창의성을 제어
        max_tokens=1000,        # 모델이 생성할 수 있는 최대 토큰 수 (일반적으로 1개 토큰은 약 4글자)
        top_p=0.95,                # 무작위성과 독창성 조절
        frequency_penalty=0,    # 모델이 예측을 반복하는 경향을 제어. 이미 생성된 단어의 확률을 낮춤
        presence_penalty=0,        # 새로운 예측을 만들도록 유도. 이미 예측된 텍스트에 단어가 나타난 경우, 해당 단어의 확률을 낮춤
        stop=None                # 모델의 응답 정지 옵션
    )

    res_message = response["choices"][0]["message"]

    # function call 응답일 경우,
    if res_message.get("function_call"):
        res_message.update({'content': None})
        available_functions = {
            "order_analysis": order_analysis,
            "remove_ponr": remove_ponr,
            "get_order_task_list": get_order_task_list,
            "close_task": close_task
        }

        # function 실행
        function_name = res_message["function_call"]["name"]
        function_to_call = available_functions[function_name]
        function_args = json.loads(res_message["function_call"]["arguments"])
        function_response = function_to_call(
            function_args
        )

        # system message 세팅 (order template 활용)
        order_template_message = make_order_template()
        order_system_message = {"role": "system", "content": order_template_message}

        # 사전 백업해뒀던 function call용 메세지에 system 메세지 및 응답 세팅
        func_messages.append(order_system_message)
        func_messages.append(res_message)

        # function 실행 결과 추가
        func_messages.append(
            {
                "role": "function",
                "name": function_name,
                "content": function_response
            }
        )

        # 함수 실행결과를 바탕으로 챗봇 응답 생성
        res = openai.ChatCompletion.create(
            engine="gpt-4",
            messages = func_messages,
            temperature=0.0,
            max_tokens=1000,
            top_p=0.95,
            frequency_penalty=0,
            presence_penalty=0,
            stop=None
        )

        assistant_turn = res.choices[0].message
        return jsonify(assistant_turn)

    if (res_message['content'] == "안녕하세요. 무엇을 도와드릴까요?"):
        return jsonify({
            "content": "안녕하세요. 무엇을 도와드릴까요?",
            "role": "assistant"
        })

    # 질의에 맞는 document가 없을 경우 (할루시네이션 방지)
    if (chroma_response['distances'][0][0] >= 0.17):
        return jsonify({
            "content": "찾으시는 정보가 없어 답변드리기 어렵습니다.",
            "role": "assistant"
        })

    return jsonify(res_message)

위는 사용자 질의에 따른 응답을 주는 코드이다. Function Call의 핵심 부분은 내가 구현한 부분이 아니므로 뺐다.
기본적인 flow는 아래와 같다.

  1. 사용자 질의가 들어오면 basic_rag_chat_completion main 함수가 실행된다.
  2. 사용자 질의를 임베딩하고 임베딩 데이터를 기반으로 ChromaDB에서 유사한 document를 가져온다.
  3. 가져온 document를 system message에 프롬프트 형태로 세팅해준다. (system message가 뭔지는 아래 참고)
  4. openai api에 만들어진 message를 던져 질의한다.
  5. 해당 질의가 function call호출을 위한 질의라면, if문으로 들어가 맞는 function을 실행시킨다.
    1. system message로 function call용 template를 세팅해준다.
    2. function 결과를 message에 세팅한다.
    3. 만들어진 message로 한번더 질의하여 받은 응답을 반환한다.
  6. function call 질의가 아니라면, if문을 skip 한다.
  7. 질의가 인사말이라면 인사말로 응답한다.
  8. 질의에 맞는 document가 없다면, 답변할 수 없는 내용이라고 답변한다.

chroma db 조회 결과 중, distances 정보가 있는데 해당 정보는
사용자 질의와 반환된 document의 연관성이 얼마나 밀접한지를 나타내는 정보이다.
distance가 1에 가까울수록 연관성이 없는 document이고,
시행착오 끝에 0.17 수치를 기준으로 실제 적절한 문서를 가져오는지 판단되는 것 같아 위와 같이 코드를 작성하였다.

위 작업으로 할루시네이션을 어느정도 방지했지만,
인사말이 들어올 경우 ChromaDB에 관련 Document가 없어 distance가 높게 나오는 바람에 인사에 대한 응답으로 찾을 수 없는 정보가 나온다는 답변이 나오게 되었다.
이를 방지하기 위해 프롬프트에 인사말이 나올 경우, 특정 메세지의 인사말로 응답해달라는 문구를 추가하고
특정 메세지의 인사말이 응답으로 나왔을 경우, 인사로 답변할 수 있도록 하단에 코드를 작성했다.

그리고 rag chat 부분과 function call 부분을 합치는 과정에서 질의에 활용되는 message에 동일한 템플릿에 세팅되다보니
function call의 동작이 제대로 되지 않아서 rag용과 function call용 template을 구분했다.

뿐만 아니라 function call 파라미터로 난수가 입력되는 등 여러 시행착오가 있었지만,
function call 부분은 내 관할이 아니므로 pass,,,

또한, 내가 구현한 부분은 아니지만, 이전 답변에 대한 history를 프론트에서 받게끔 해서
이전 답변을 고려한 답변이 될 수 있도록 하였고,
문서 형식에 상관없이 UI를 통해 문서를 업로드하면 해당 문서의 내용을 파싱하여 임베딩, Vector DB저장까지 될 수 있도록 구현되었다.

프롬프트는 아래 조건이 녹아들어갈 수 있도록 구성했다.

  1. 질문을 시작하기 전에 대답하는 ChatGPT에 역할부여
  2. 질문하는 사용자의 구체적인 목표를 제시
  3. 사용자가 원하는 구체적인 대답방법을 제시
  4. 얻고 싶은 결과 형식을 명확하게 제시
  5. 제한된 대답의 길이를 제시
    그리고 추가적으로 위에 언급한 것처럼 인사말에 대한 응답이 인사말이 될 수 있도록하고
    대답에 참고한 문서의 정보(file name)를 전달할 수 있도록 두 가지 문구를 추가했다.

그리고 질의를 할 때, message는 role과 content라는 두 가지 속성을 가지는데,
content에는 실제 내용이 들어가고, role에는 system과 user, assistant, function 등과 같이 role에 관한 값이 들어간다.
해당 role에 따라 content 처리 방법이 달라진다. 아래는 role에 따라 주로 어떤 내용이 들어가는지 매핑한 내용이다.

  • user : 사용자 질의
  • system : 프롬프트 템플릿 + document
  • assistant : 이전 답변 내용 (history)
  • function : function call에 의한 함수 실행 결과

시연 및 발표

시연과 발표준비도 만만치 않았다... (문서작업이 가장 어려운 개발자들...)

시연영상 촬영을 위한 시나리오를 짜기전에 코드를 합쳤는데, 위에서 언급했던 여러가지 문제가 발생해서 해결하느라 시간 다 썼다.
그리고 부랴부랴 시나리오 정해서 영상촬영까지 끝낸 후 마감시각 정각에 파일들을 제출할 수 있었다.
해커톤 팀의 과장님이 발표를 담당하셨는데, PPT 제작에도 많은 힘을 쓰신 것 같았다.

그리고 한가지 과제가 또 있었다...
시연과 코드리뷰가 Jupyter 상에서 진행된다고 하여 APP형태의 코드들을 모두 Jupyter 환경으로 옮기는 데도 힘이 들었다.
환경이 다르다 보니... 라이브러리 설치가 안되는 부분도 많았고 해커톤 팀원 셋이서 정말 애썼다.
그리고 코드리뷰 시간이 다 되어가서 겨우 Jupyter 환경으로 모두 옮길 수 있었다. (과장님 발표하는 동안에도 계속 환경 옮기고 있었음;;)

과장님 발표는 성공적이었다. 평가하시는 분들과 같이 보시는 분들의 반응이 좋았다.
과장님께 직접 말씀드리기는 어렵지만... 영업사원 같았다. 우리가 구현한 시스템이 어디에 쓰이고 어떤 기대효과가 있고, 어떤 기술이 쓰였는지 어필을 잘 하셨다. 발표시간이 다 되어 뒷 부분 발표를 못하게 되었는데도, 심사자분께서 뒷 내용이 궁금하다고 하시어 뒷 내용도 마저 발표할 수 있었다.
그리고 우리가 구현한 시스템에 대한 자신감도 엿보였다. 발표를 할 때에는 내 제품에 대한 확신과 자신감이 있어야 청중의 공감을 살 수 있다는 것을 몸소 깨닫게 되었다.

그리고 코드리뷰 시간의 리뷰어 분들의 반응도 좋았다.
RPA쪽을 구현한 나는 따로 RPA코드를 설명드리고 있었고, 다행히 RPA활용 점수를 받을 수 있었다.
그리고 바로 옆에서 우리의 APP코드 리뷰를 진행하셨는데, 리뷰어 분께서 제공한 샘플보다 코드를 더 잘 짠 것 같다는 평가도 받을 수 있었다.
그리고 프롬프트 엔지니어링에 신경쓴 티도 난다고 하시는 등 호평이 이어졌다.
본선에 대한 부푼 기대감을 안고 리뷰장을 나왔다. 그리고 22시, 모든 발표가 마무리 되었다.

이제 앞서 받은 실무평가를 통해 20팀중 단 7팀만이 임원평가를 받을 수 있는 자격이 주어지고 대상, 최우수상, 우수상, 장려상 입상을 할 수 있다.
해당 결과는 다음날 발표되지만, 우리는 입상에 대한 기대감이 부푼 상태였다. 이제 임원평가를 위한 발표를 준비해야 했다.
물론 실무평가 때 활용한 발표를 그대로 활용할 수 있지만, 타겟이 임원이므로 기술적인 내용보다는 개발하게 된 동기나 기대효과가 발표의 주를 이룰 수 있도록 수정하는 것이 좋다고 판단되었다. 그리고 이전에 촬영한 시연영상은 임원평가 때 보여지기 때문에 영상의 퀄리티를 높이기 위해 시나리오를 재작성해서 재촬영했다. 그리고 우리는 새벽 2시경에 숙소에 들어갈 수 있었다.

다음 날 10시, 발표가 진행되는 대강당에 집결했다.
예상했던대로 임원평가 대상자로 우리팀이 선발되었다. 장려상까지는 확보한 것이었다.
과장님의 발표가 진행되고, 어제보다 침착해진 어조로 우리가 구현한 시스템의 기대효과를 어필하셨다. 임원평가 때의 반응도 좋아보였다.
발표장에는 우리회사 사장님이 IT부문장 자격으로 참관하고 계셨다. 실물로 처음 뵙는 분인데, 악수까지 청해주셔서 영광이었다. (사장님 fan이된 ssul푼다)
우리팀의 발표를 보신 이후에도 comment를 남겨주셨는데, 발표에 나온 내용은 본부장과도 카톡으로 얘기를 나누고 있던 내용이었다... J과장과 사원들이 자기계발에 관심이 많은 것을 알고있다... 등 호평도 많이 주셨다. 이러다가 대상까지 받는 것 아닌지 김칫국을 원샷했다.

결과

결과는 우수상이었다.
사실 우수상도 감사하지만, 최소 최우수상이라고 생각했던 우리팀은 조금 아쉬워했다. 기대를 많이해서 그런 것 같다.
그래도 우수상이라도 받을 수 있어서 좋았다.

해커톤에 참여하여 어떤 서비스를 만드는 일 자체도 재밌었는데, 상을 수상하여 더욱 보람된 9~10월이 될 수 있었다. 끝!

반응형

'개발 > AI' 카테고리의 다른 글

[AI] pandas/sklearn을 활용한 머신러닝 모델링  (0) 2024.04.12
[AI] pandas를 활용한 데이터 핸들링 및 전처리  (2) 2024.04.10
KT AI 해커톤 회고 (1)  (0) 2023.10.27
[AI] Softmax Regression  (0) 2023.10.01
[NLP] Embedding  (0) 2023.09.23
728x90
반응형

23년 8월 어느 날, KT AI 해커톤공고가 올라왔다.
개발에 목말라있던 나에겐 당연히 좋은 기회라고 생각했다.

하지만 우려되었던 부분도 있었다.
PM으로 리딩하고 있던 프로젝트가 10월까지 계획되어 있어서 업무부담이 가중되지 않을까란 우려와
Java 개발에 익숙한 내가 Python과 AI기술을 활용하여 개발을 할 수 있을까란 우려가 있었다.

그렇지만 10월까지는 야근과 주말근무도 감수하면 될꺼고... AI에도 관심이 있던 상태였다. 시원하게 저질렀다. AI 해커톤 참여하는 것으로...!

예선

AI 해커톤 지원 후, 얼마 지나지 않아 예선 과제와 일정이 주어졌다.
예선 과제는 KT고객의 고장문의와 고객단말 시험결과를 기반으로 하여 현장기사 출동/무출동을 판단하는 문제였다.
그리고 출동/무출동을 판단하여 정확도/정밀도 순으로 상위 20팀을 선발되어 본선에 참여할 수 있는 기회를 얻게된다.

그리고 기한도 함께 나왔다.
하필 내 해외여행 일정이 겹쳤다;;; 사전에 잡은 일정이라 취소도 어려웠다.

팀에 최대한 누가 되지 않도록 나는 일정을 앞당겨 먼저 예선에 몰두했다.
먼저 정확도/정밀도가 뭔지 개념을 파악하고, 기본적인 머신러닝 이진분류 알고리즘의 원리를 파악했다. (👉이진분류 알고리즘(Logistic Classification) 글 참고)

정확도/정밀도

정확도와 정밀도의 개념은 사진을 참고하면 된다.

쉽게말하면,
정확도는 전체 데이터중 모델이 예측한 True/False와 실제 값이 일치하는 경우의 비율이다.
(ex. 모델 예측 : 1=True, 2=True, 3=False, 실제 : 1=True, 2=False, 3=False, 전체 데이터 갯수 : 3개 (1, 2, 3), 일치하는 데이터 갯수 : 2개 (1, 3), 정확도 = 2 / 3 = 66.67%)

정밀도는 True/False로 예측한 데이터 중에서 실제 True/False로 일치하는 경우의 비율이다.
아래 예시는 모델이 True로 예측한 데이터 중에서 실제 True의 비율을 나타내는 예시이다.
(ex. 모델 예측 : 1=True, 2=True, 4=True, 실제 : 1=True, 2=True, 4=False, True 예측 데이터 갯수 : 3개 (1, 2, 4), 일치하는 데이터 갯수 : 2개 (1, 2), True정밀도 = 2 / 3 = 66.67%)

이번 예선에서는 정확도/정밀도 순으로 상위 20팀을 선발한다.
정확도 뿐만 아니라 정밀도도 평가에 반영되기 때문에, 모든 데이터의 기본 값을 출동으로 하여 무출동 값을 선별하는 것이 아닌, 적절한 출동/무출동 예측을 진행해야 한다.

출동/무출동 판단

평가기준이 정확도 70%, 정밀도 30%로 순위를 나열해서 본선진출팀을 선발되는 방식이기 때문에 어느정도 전략을 잘 짜야했다.
다행히 같이 참여하시는 과장님께서는 전년도에도 AI해커톤을 참여하신 경력이 있으시기에 전략짜기 한결 수월했다.

일단 과제해결 방식은 이렇다.
python코드를 활용하여 출동/무출동을 판단하는 AI모델을 구현한 후에 출동/무출동 라벨이 달려있는 학습용 데이터를 지도학습시키고,
라벨이 없는 테스트용 데이터를 출동/무출동 판단하여 데이터에 출동/무출동을 매핑한 후에 시스템에 제출하면 된다.
그러면 시스템은 정확도와 정밀도를 계산하여 자동으로 팀별 순위를 나열해주는 방식이다.

테스트용 데이터의 출동/무출동 비율을 알아보기 위해 우리는 모든 테스트용 데이터에 출동 라벨을 붙여 시스템에 제출하였다.
출동/무출동 둘중 어떤 데이터를 디폴트로 하여 어떤 데이터를 찾을지 결정하기 위함이다.
테스트 결과, 대략 출동 데이터 75%, 무출동 데이터 25% 정도로 구성되어 있는 것으로 확인되었다.
결론적으로 테스트용 데이터에 출동데이터를 디폴트로 하여 라벨링을 한 후에 어떤 데이터가 무출동인지만 찾아서 해당하는 데이터에 무출동을 라벨링하여 제출하면 된다. 단, 위에서도 언급한 것처럼 정밀도도 평가에 반영되기 때문에 적절한 무출동 값 예측도 필요하다.

먼저 과장님이 기반코드를 공유해주셨다.

import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import f1_score, precision_score, classification_report
import seaborn as sns
import numpy as np

train_base = pd.read_csv('../data/dfBase_Train.csv') # load 학습데이터
train_detail = pd.read_csv('../data/dfDetail_Train.csv')
test_base = pd.read_csv('../data/dfBase_Test.csv') # load 테스트데이터
test_detail = pd.read_csv('../data/dfDetail_Test.csv')

train_base.columns = ['고장접수번호', '현장출동구분', '신고내용'] # 컬럼명 설정
test_base.columns = ['고장접수번호', '신고내용']
train_detail.columns = ['고장접수번호', '시험종류', '시험수행일', '시험수행성공여부', '시험수행상세결과']
test_detail.columns = ['고장접수번호', '시험종류', '시험수행일', '시험수행성공여부', '시험수행상세결과']

def clacul(row):
    target_id = row.고장접수번호
    detail_df = train_detail[train_detail.고장접수번호 == target_id]
    detail_df_len = len(detail_df)
    return detail_df_len
res = train_base.apply(lambda x: calcul(x), axis=1) 
train_base['시험횟수'] = res # 문의별 단말시험횟수 값 계산 및 저장

train_detail['시험요약'] = train_detail['시험종류'] + '-' + train_detail['시험수행성공여부']
train_detail_pivot = train_detail[['고장접수번호', '시험요약', '시험수행성공여부']].pivot_table(index='고장접수번호', columns='시험요약', aggfunc='count') # 고장접수번호별 시험종류-시험수행성공여부 갯수를 기록한 피벗테이블 생성

train_detail_pivot = train_detail_pivot.fillna(0) # NaN -> 0.0

train_detail_pivot = train_detail_pivot.reset_index().droplevel(level=0, axis=1).rename(columns={'':'고장접수번호'})
train_merged = train_base.merge(train_detail_pivot, left_on='고장접수번호', right_on='고장접수번호') # pivot테이블을 기존테이블에 merge

train_merged['장애안내'] = train_merged.신고내용.map(lambda x:1 if(('장애안내' in x) | ('장애 안내' in x)) else 0) # '장애안내' 키워드 갯수 카운트 및 매핑

rf = RandomForestClassifier() # RandomForest 모델 선언
X_data = train_merged.drop(columns=['현장출동구분', '고장접수번호', '신고내용'])
y_data = train_merged['현장출동구분']

rf.fit(X_data, y_data) # 학습

위 코드를 간단히 설명하면,
고장문의마다 해당 고객의 단말시험을 진행하는데
문의별로 어떤 단말시험을 진행했는지? 결과는 어땠는지? 그리고 문의내용 중 특정 키워드가 있는지의 정보를 만들어서
RandomForest 모델에 학습시키는 코드이다.

위 코드로 모든 데이터를 출동으로 명시한 경우의 75%에서 76%까지 1%의 정확도를 올릴 수 있었다.

그리고 나는 무출동 데이터를 판별하는 핵심 키워드를 추출하기 위해 아래 코드를 추가하였다.

# 문자열 가공
def extract_word(text):
    text = text.lower()
    text = re.sub('[^a-z가-힣.%]', ' ', text)
    text = re.sub('\s+', ' ', text)
    return text
train_base['신고내용'] = train_base['신고내용'].apply(lambda x: extract_word(x))

# 형태소 분석
from konlpy.tag import Okt

okt = Okt()
words = " ".join(train_base['신고내용'].tolist())
words = okt.morphs(words, stem=True)

# 키워드 중복제거 및 count
from collections import Counter
frequent = Counter(words).most_common()

# 키워드 추출 ( 조건 : 2 * 무출동 >= 출동 )
for i in range(len(frequent)):
    check = train_base[(train_base.신고내용.str.contains(frequent[i][0]))].현장출동구분.value_counts()
    if "무출동" in check:
        ngo = check['무출동']
    else:
        ngo = 0

    if "출동" in check:
        go = check['출동']
    else:
        go = 0

    if (2 * ngo >= go):
        print(frequent[i][0])

위 코드를 간단히 설명하면,
모델에 키워드를 학습시켜 정확도를 향상시키기 위해 키워드를 추출하는 코드이다.
키워드 분석을 진행해보면,
일반적으로 특정 키워드가 신고내용에 포함되어 있는 경우 중 출동/무출동 라벨이 붙어있는 비율을 보면 대략 (출동 : 무출동 = 3 : 1) 의 비율이 형성되어 있다. 그래서 이보다 더 높은 무출동 비율의 (ex. 출동 : 무출동 = 2 : 1) 키워드를 찾아내서 학습시키면 정확도를 올릴 수 있을 것이라고 판단했고,
심사기준에 정밀도가 포함되어 있음을 고려하여 출동:무출동=2:1 비율 이상의 무출동 비율의 키워드를 추출할 수 있도록 조건을 설정하였다.

그리고 해당 코드와 핵심 키워드 몇개를 추출하여 해커톤팀에 공유하였다.
그 후에 나는 팀원들에게 뒤를 맡기고 예정된 해외여행 일정을 소화했다...

결과

팀원들이 남은기간 키워들을 잘 찾아내어 학습시켜주었기 때문에, 정확도를 77.6512까지 올릴 수 있었고
105팀중 최종 7위로 인간지능팀인 우리가 본선진출에 성공했다.

이제 본선을 대비해서 사전 개발작업을 했는데, 본선 후기는 👉다음글에서...

반응형

'개발 > AI' 카테고리의 다른 글

[AI] pandas를 활용한 데이터 핸들링 및 전처리  (2) 2024.04.10
KT AI 해커톤 회고 (2)  (1) 2023.11.02
[AI] Softmax Regression  (0) 2023.10.01
[NLP] Embedding  (0) 2023.09.23
[AI] Logistic Regression  (0) 2023.09.10

+ Recent posts