IT/AI\ML

[python] AI엔지니어가 주의해야할 python 코드 작성 포인트

개발자 두더지 2022. 4. 13. 16:51
728x90

일본의 한 포스팅을 번역한 글입니다. 오역 및 직역, 의역이 있을 수 있으며 틀린 내용은 지적해주시면 감사하겠습니다. 

 

 이번 포스팅에서는 데이터 사이언티스트, AI 엔지니어가 Python 프로그램을 작성할 때 주의해할 점과 노하우 등을 정리해보았다.

 

AI 엔지니어가 주의해야할 python 코드 작성 포인트


이번 포스팅에서는 다음의 내용들에 대해서 다룰 것이다.

 

레벨 1

1.1 변수, 함수, 클래스, 메소드의 명명규칙을 지키기

1.2 명명방법1 : 변수명이나 메소드명에서 장황한 부분은 삭제하기

1.3 import의 기재는 룰에 따르기

1.4 랜덤수의 seed는 고정하고, 재현성을 보장하기

1.5 프로그램은 함수화하여 실행하기

 

레벨2

2.1 명명방법2: reverse notation(역기법)으로 명명하고, 읽기 쉽게 작성하기

2.2 SOLID의 S를 의식해, 함수, 메소드가 하나의 일을 하도록 간략하게 만들기

2.3 함수, 메소드에는 타입 힌트를 작성하기

2.4 클래스, 메소드, 함수에는 docstring을 기재해두기

2.5 학습이 끝난 모델의 저정할 때, 전처리나 하이퍼파라미터등의 정보도 함께 저장하기

 

레벨3

3.1 명명방법3: 적당한 영어 단어와 품사로 책무를 알 수 있는 이름을 붙이기

3.2 적절한 예외처리 구현하기

3.3 적절한 로그를 구현하기

3.4 함수, 메소드의 인수는 3개 이하로하기

3.5 *args, **kwargs를 적절히 사용하기

 

레벨4

4.1 삼항연산자로 if문을 짧게 쓰기

4.2 sklearn 준거로, 전처리 및 모델의 클래스를 구현하기

4.3 데코레이터를 적절히 활용하기

4.4 팀 개발용으로 에티터 설정을 통일하기

4.5 GitHub의 풀리퀘스트용 template를 준비하여 주의점을 기재해두기

 

 

레벨 1의 설명


1.1 변수, 함수, 클래스, 메소드의 명명규칙을 지키기

 함수명이나 변수명을 작성할 때 고민될 것이라고 생각된다. 명명방법도 중요하지만, 먼저 룰로써의 명명규칙을 지키자.

- 변수, 함수, 메소드, 모듈

소문자만 사용한다. 필요에 따라 단어는 언더스코어(_)로 구분한다.

(예) lower_case_with_underscroes

- 클래스명

맨 앞은 대문자로 작성하며, 언더스코어는 사용하지 않는다. 

(예) Capwords

- 클래스내에서만 사용되는 프라이빗 변수

변수명 맨 앞에 언더스코어를 붙인다.

(예) _single_leading_underscores

클래스내에서만 사용되는 프라이빗 메소드 또한 메소드명 앞에 언더스코어를 붙인다.

(예) _single_leading_underscore(self, ...)

-정수

대문자만 사용한다. 단어는 언더스코어로 구분한다.

(예) ALL_CAPS_WITH_UNDERSCORES

- 패키지명

소문자만 사용한다.

(예) lowers

[참고1] 함수와 메소드의 차이점

함수는 클래스안에 독립된 것이며, 메소드는 클래스 내에 있는 함수를 일컫는다.

[참고2] 모듈과 패키지

패키지는 제일 큰 탑 레벨이다. 모듈은 패키지 내의 파일이다. 예를 들어, sklearn은 패키지이며, sklearn.linear_model의 linear_model은 모듈이다.

 

1.2 명명방법1 : 변수명이나 메소드명에서 장황한 부분은 삭제하기

 예를 들어 클래스 class_1에, 변수 max_length가 있는 경우, 멤버 변수의 이름은 class_1_max_length라고 하지말고, 단순히 max_length로 한다.

예를 들어, 다른 클래스에서 이 클래스 변수에 액세스할 때에 class_1.class_1_max_length = 10과 같이 클래스명이 확장되므로 class_1.max_length = 10 처럼 짧아진다. 

 즉, 멤버 변수는 사용될 때, '클래스명.변수명"이 될 것을 상정한 상태로 명명하는 것이다.

 

1.3 import의 기재는 룰에 따르기

 외부 클래스나 함수를 import 할 때에 주의해야할 점은 세 가지 설명하도록 하겠다.

- import를 기재하는 순서

 아래와 같이 3개의 타입 라이브러리를 공백행으로 구분하여 기재한다. 

import 표준라이브러리
공백행
import 서드파티와 관련된 것(PyPI에서pip install한 것들)
공백행
import 이번에 사용하려고 작성한 것
공백행

- import의 기재 방법1

모듈 그자체를 import 한다. 예를 들어 pkg의 모둘 module_1의 클래스class_1가 존재한다고 가정했을 경우,

from pkg.module_1 import class_1가 아닌, from pkg import module_1로하여, 프로그램내에서는 예를 들어 my_class = module_1.class_1()과 같이 모듈 레벨로 사용한다.

- import의 기재 방법2

한 줄의 import로 여러 개의 패지키지를 import하지 않는다.

import pkg, pkg2가 아닌 아래와 같이 한 줄 씩 작성한더ㅏ.

import pkg

import pkg2

그러나, 모듈의 경우 한 줄의 import에 여러 개를 기재해도 OK이다. 예를 들면 다음과 같다.

from pkg import module_1, module_2

 

1.4 랜덤수의 seed는 고정하고, 재현성을 보장하기

 데이터 사이언스, AI의 구현시에 랜덤한 부분이 많으므로, 랜덤수의 seed를 반드시 고정해, 프로그램의 재현성을 보장해야한다. 구현 예는 다음과 같다.

import os
import random
import numpy as np
import torch

SEED_VALUE = 1234  # 여기는 뭐든지 괜찮다.
os.environ['PYTHONHASHSEED'] = str(SEED_VALUE)
random.seed(SEED_VALUE)
np.random.seed(SEED_VALUE)
torch.manual_seed(SEED_VALUE)  # PyTorch를 사용한 경우

 그러나, PyTorch로 GPU를 사용할 경우 더욱이 아래의 설정을 해준다. 이 설정을 해주지 않으면 GPU이용시에 재현성이 보장되지 않는다.

torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False

 그러나, torch.backedns.cudnn.deterministic = True는 GPU로 계산 속도가 떨어진다. 그러므로 본인(원 포스팅의 저자)의 경우, 실행 속도를 고려하여 GPU의 학습 재현성이 보장되는 것까지는 하지 않는다.

 또한 sckit-learn의 경우는 각 알고리즘에 랜덤수 시드를 받는 부분이 있으므로, 그곳을 고정한다. 예를 들면 다음과 같다.

from sklearn.linear_model import LogisticRegression

SEED_VALUE = 1234  # 여기는 뭐든지 괜찮다.
clf = LogisticRegression(random_state=SEED_VALUE)

 

1.5 프로그램은 함수화하여 실행하기

 Jupyter Notebook에서도 Python 커맨드라인 실행에서도 함수화되어 있지 않은 프로그램은 실행 속도를 늦춘다. 예를 들어, Google Colaboratory에서, 아래의 코드를 실행시키면 8.4초 정도가 걸린다.

import time
import numpy as np

start = time.time()

data = np.random.rand(5000)
sum = 0

for i in range(len(data)):
    for j in range(len(data)):
        sum += data[j]

elapsed_time = time.time() - start
print(elapsed_time)

 이는 1개의 셀에 프로그램을 늘여쓰고 실행하고 있는 상태이다. 동일하게 1개의 셀에 실행하지만, 메인의 for문 근처를 함수화하여 실행해보자.

import time
import numpy as np


def main():
    data = np.random.rand(5000)
    sum = 0

    for i in range(len(data)):
        for j in range(len(data)):
            sum += data[j]
    return 0


start = time.time()
main()
elapsed_time = time.time() - start
print(elapsed_time)

 그 결과 6.2초가 걸렸다. 이 와 같이 Jupyter Notebook에서 실행해도, 오랜 시간이 걸리는 처리는 줄줄이 늘여쓰지 말고, main()과 같은 함수로 하여, 그 함수를 실행하도록 하자.

 커맨드라인에서 실행하는 python 파일, 예를 들어, hogehoge.py를 실행하는 경우도 동일하다. python hogehoge.py를 실행했을 때에 실행이 늦어지지 않게 다음과 같이 작성하자.

# import종류
import fuga

# main함수내에서 사용하는 함수나 클래스
def piyo():
    your code

# main함수
def main():
    your code

if __name__ == "__main__":
    main()

 마지막으로 if __name__ == "__main__":에서 main()의 실행을 if문은 안에 넣은 이유는 import hogehoge했을 때 이 main()의 함수가 실행되지 않게 하기 위함이다. 이 if문이 ㅇ벗으면 import하는 것으로 maIn()이 실행되어버린다.

 

 

레벨2의 설명


2.1 명명방법2: reverse notation(역기법)으로 명명하고, 읽기 쉽게 작성하기

 예를 들어, a의 길이, b의 길이, c의 길이와 같이 세 종류의 변수를 만들고 싶을 때, 

a_length, b_length, c_length로 작성하는 것이 아닌 length_a, length_b, length_c로 쓴다. 이러한 작성 방법을 reverse notation(역기법)이라고 부른다. reverse notation의 경우, 단어의 맨 앞이 동일하므로, 읽기 쉬운 프로그램이된다.

 또한, 길이가 아닌, a에 주목하고 있는 경우는 a_length, a_width, a_max_length와 같이 쓴다. 정리하자면 "주목해야할 대상을 맨 앞에 써, 변수의 맨 앞 부분을 통일"하는 것이다.

 

2.2 SOLID의 S를 의식해, 함수, 메소드가 하나의 일을 하도록 간략하게 만들기

 로버트 C 마틴이 제안한 소프트 웨어 설계의 원리가 SOLID이다. 여기서는 SOLID의 모든 것을 인식할 필요는 없다. 그러나 SOLID의 첫 번째인 S, Single responsibility principle(단일 책임 원리)은 기억해두자.

 "단일 책임"이란 "함수나 클래스, 그리고 메소드는 단 하나의 책무만 진다"는 의미이다. 여기서 "단 하나"의 정의가 조금 와닿기 힘들 수 있는데, 최대한 함수나 클래스, 메서드를 짧게 하여 상위 개념 변경에 영향을 받도록 한다고 생각하면 된다. 

 데이터 사이언티스트, AI엔지니어가 하는 작업은 "데이터 전처리, 학습, 추론 ..."과 같이 정형적인 흐름적인 내용이다. 그러면, 구현할 프로그램도 위의 순서와 동일한 절차되어, 하나의 main 클래스나 메소드가 비대해지고, 단일 클래스나 메소드로, 위에서 아래로 순서대로 많은 일(많은 책무)하도록 되기 쉽다.

 Jupyter Notebook에서 그치는 레벨이라면 괜찮지만, 시스템 개발에 데이터 사이언스, AI를 도입하기 위해서는 이 상태라면 좋지 않다.

 AI 시스템에 있어서 SoE(System of Engagement)의 개발은 SoR(System of Records)와 같이 요건 정의, 외부/내부 설계를 제대로하는 워터폴 개발이아닌, 에자일 개발형태가 많다.

 에자일 개발에서는 CI(Continuous Imtergration) = 자동 테스트를 실시하는 것이 기본이다. 따라서 에자일이므로, 실제로는 만든 프로토 타입 레벨에서 동작을 살펴보고, 개선 변경점을 발견하여 보다 좋은 것을 만드는 것을 목적으로 한다.

 이 "개선"의 때에 단일 클래스나 메소드의 책무가 크면 클수록 고쳐야할 코드의 행수도 많아진다. 고쳐야할 코드 행수가 많으면 영향 범위도 커진다. 그로인해 새롭게 만들 필요가 있는 단체 테스트도 늘어난다. 동시에 지금까지 만든 단체 테스트도 대부분 버려지게 된다.

 이러한 단체 테스트의 대폭적인 수정/변화가 빈번하게 발생하는 상태에서 개발을 하면, 단체 테스트를 제대로 할 수 없게 되고, 시스템 자체의 품질도 떨어지게 된다. 또한, 개선을 위한 수정, 상정하지 않았던 곳에서 영향을 일으켜 버그가 발생하게 쉽게 된다.

 

2.3 함수, 메소드에는 타입 힌트를 작성하기

 "SOLID의 S:단일 책무"를 의식하여, 함수나 메소드를 분할하면, 많은 함수, 메소드가 생성된다. 많은 수의 함수, 메소드가 있으면 이해하기 힘들다. 코드를 작성할 때에는 괜찮지만, 그 코드를 3개월 후에 수정하거나, 다른 사람이 사용하게 될 경우에 "이 함수의 인수에는 무엇이 들어 있고, 어떤 것이 출력되는가?"라는 혼란한 상태가 된다.

 따라서 함수를 구현할 때에 타입힌트를 붙이자. 타입 힌트는 아래와 같이 작성한다.

def calc_billing_amount(amount: int, price: int) -> int:
    billing_amount = amount*price
    return billing_amount

 인수의 변수 데이터형이 무엇인지 알 수 있도록 인수명 뒤에 데이터형을 기재한다. 또한 함수에서 출력된 변수의 데이터형을 알 수 있도록 데이터형을 기재한다.

 이 타입힌트는 그 이름 그대로 힌트이며, 강제는 아니다. 따라서 위의 함수에서는 맨 처음의 amount에 int가 아닌 float를 부여해 calc_billing_amount(0.5, 100)을 하여도 실행되며 에러가 발생하지 않는다. 이 점을 주의할 필요가 있다.

 타입힌트로 리스트나 사전형을 사용할 경우나, int나 float 여러 개의 데이터형을 허용하고 싶은 경우 다음과 같이 작성한다.

from typing import Dict, List, Union


def calc_billing_amount(
    amount_list: List[int], price_dictionary: Dict[str, Union[int, float]]
) -> int:
    billing_amount = 0
    for index, (key, value) in enumerate(price_dictionary.items()):
        billing_amount += amount_list[index] * value

    return int(billing_amount)

 from typing import List, Dict, Union으로 타입힌트용 리스트, 사전형 그리고 어느 데이터형도 괜찮은 경우에 사용할 Union을 import한다.

 그리고 예를 들어 요소가 int형 리스트인 경우는 List[int]로 기재하고, key가 string형 그리고 value가 int 혹은 float 어느 한쪽이면 괜찮은 경우 Dict[str, Union[int, float]]로 작성한다. 

 실행을 해보면, 605가 출력된다.

amount = [3, 10]
price = {"item1": 100, "item2": 30.5}
calc_billing_amount(amount, price)

 또한, 우리쪽에서 정의한 오리지널 클래스를 타입힌트로 하고 싶은 경우는 다음과 같이 작성한다.

class User:
    def __init__(self, name: str, user_type: str):
        self.name = name
        self.user_type = user_type


def print_user_type(user: "User") -> str:
    print(user.user_type)

 만든 클래스 User를 정의하고, 그 User를 인수에 실행되는 함수 print_user_type를 정의하고 있다. 

 Python 버전 3.7 이하인 경우 from __future__ import annotations를 사용해 "User"을 User로 할 수 있는 듯하지만, Google Colaboratory도 Python 버전은 아직 3.6이므로, 위의 작성법을 추천한다.

 위의 타입 힌트를 전달받은 클래스를 실행하면 보통과 같다. 출력은 admin으로 표시된다.

taro = User("taro", "admin")
print_user_type(taro)

 타입 힌트를 작성하는 것은 번거롭지만, 단일 책임의 영향으로 생긴 많은 클래스, 메소드를 파악하기 쉬워지므로 추천한다.

 

2.4 클래스, 메소드, 함수에는 docstring을 기재해두기

 docstring은 클래스나 메소드, 함수의 사양이나 사용방법을 설명하는 것이다. 앞서 말했듯, 단일 책임 원칙으로 생긴 많은 클래스나 함수를 쉽게 파악하기 힘들다. 타입힌트만으로는 여전히 알기 어렵기 때문에, 상세 설명으로 docstring을 작성한다. 

 그러나 너무 손이 가는 일이므로, 프라이빗 메소드나 행의 수가 적은 메소드 등은 1행은 docstring으로 충분하다고 생각된다. 한편으로, 다른 팀 멤버도 사용하는 클래스나 메소드, AI시스템에서 중요한 위치를 차지하고 있는 메인 클래스, 처리가 긴 클래스 등은 상세한 설명있는 것이 좋다.

 docstring의 작성법은 어떠한 방법이든 상관없지만, 보통은 reStructuredText, Google style, Numpy style이 많이 사용된다. 이 세 가지 중 하나를 쓰는 이유는 나중에 Sphinx를 사용하면 자동으로 문서화할 수 있기 때문이다. 따라서 Sphinx에서 대응하고 있는 docstirng의 기법을 채용했다. docstring의 세가지 종류의 설명은 나중에 기회가 되면 추가로 다루도록 하겠다. 

 Google style과 Numpy style은 꽤 길기 때문에 필자(원글의 저자)는 reStructuredText를 좋아한다. reStructuredText에서의 docstring은 아래와 같이 작성한다.

class User:
    """본 시스템을 사용하는 어카운트 유저를 표시하는 클래스이다.

    :param name: 유저의 어카운트 명
    :param user_type:어카운트 타입(admin인가 normal인가)

    :Example:

    >>> import User
    >>> taro = User("taro", "admin")
    """

    def __init__(self, name: str, user_type: str):
        self.name = name
        self.user_type = user_type

    def print_user_type(self):
        """유저의 타입을 print문으로 출력한다.

        :pram None: 입력 인수는 없다.
        :return: user_type를 문자열로써 출력한다.
        :rtype: str

        :Example:

        >>> import User
        >>> taro = User("taro", "admin")
        >>> taro.print_user_type()
        admin
        """
        print(self.user_type)

 Example까지 써두면, 굉장히 쓰기 쉬워진다. 어디까지 자세하게 쓸 것인가는 Example이나 인수, return의 설명을 쓴 다음이 된다. 

 위와 같이 기재해주면, VS code등의 Editor에서는 아래의 그림과 같이 해당 프로그램 부분에 마우스 커서를 올리면, 이 docstring이 표시되므로 프로그램을 이해하기 쉬워진다(아래의 그림에서는 print_user_type()에 마우스 커서가 올라가 있다).

 

2.5 학습이 끝난 모델의 저정할 때, 전처리나 하이퍼파라미터등의 정보도 함께 저장하기

 AI, 머신러닝, 딥러닝에 있어서, 학습이 끝난 모델만을 저장하는 것이아닌 전처리 파라미터, 모델의 하이퍼 파라미터의 설정 등, 학습의 재현할 수 있는 정보, 그리고 추론하기 위해 필요한 모든 오브젝트를 저장한다.

 이러한 모든 정보가 포함되어 있으면 어떠한 저장방법이든 상관없으나, scikit-learn과 PyTorch에서의 저장예는 다음과 같다.

예 : scikit-learn의 경우

from datetime import datetime, timedelta, timezone

import numpy as np
from joblib import dump, load
from sklearn.datasets import load_iris
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import PolynomialFeatures, StandardScaler

# 해당 데이터로써 iris를 준비
X, y = load_iris(return_X_y=True)

# 전처리(표준화하여, 제곱항의 특징량을 추가)
preprocess_pipeline = Pipeline(steps=[("standard_scaler", StandardScaler())])
preprocess_pipeline.steps.append(("polynominal_features_2", PolynomialFeatures(2)))

# 전처리의 적용
X_preprocessed = preprocess_pipeline.fit_transform(X)

# 학습기의 준비
C = 1.2  # 하이퍼 파라미터의 설정
model = LogisticRegression(random_state=0, C=C)

# 학습의 실시
model.fit(X_preprocessed, y)

# 학습 데이터의 성능
accuracy_training = model.score(X_preprocessed, y)

# 저장할 각 종 데이터의 준비
JST = timezone(timedelta(hours=+9), "JST")  # 일본의 날짜, 시각
now = datetime.now(JST).strftime("%Y%m%d_%H%M%S")  # 현재 시각

training_info = {
    "training_data": "iris",
    "model_type": "LogisticRegression",
    "hyper_pram_logreg_C": C,
    "accuracy_training": accuracy_training,
    "save_date": now,
}

save_data = {
    "preprocess_pipeline": preprocess_pipeline,
    "trained_mode": model,
    "training_info": training_info,
}
filename = "./iris_model_" + now + ".joblib"

# 저장
dump(save_data, filename)

 이렇게 저장한 내용을 로드할 경우에는 아래와 같이 작성한다.

load_data = load(filename)

# 로드한 내용을 읽어들이기
preprocess_pipeline = load_data["preprocess_pipeline"]
model = load_data["trained_mode"]
print(load_data["training_info"])

 이 로드의 예에서는 training_info를 print하고 있으므로, 다음과 같이 출력된다.

{'training_data': 'iris', 'model_type': 'LogisticRegression', 'hyper_pram_logreg_C': 1.2, 'accuracy_training': 0.9866666666666667, 'save_date': '20200503_205145'}

예: PyTorch의 경우

PATH = './checkpoint_' + str(epoch) + '.pt'

torch.save({
    'epoch': epoch,
    'total_epoch': total_epoch,
    'model_state_dict': model.state_dict(),
    'scheduler.state_dict': scheduler.state_dict(),
    'optimizer_state_dict': optimizer.state_dict(),
    'loss_train': loss_train,
    'loss_eval': loss_eval,
}, PATH)

로드할 경우는

# 모델 등의 오브젝트를 먼저 만든다.
model = TheModelClass()  # 이것은 저장한 것과 동일한 모델
scheduler = TheSchedulerClass()  # 이것은 저장한 것과 동일한Scheduler Class
optimizer = TheOptimizerClass()  # 이것은 저장한 것과 동일한Optimizer Class

# 로드해서 전달한다.
checkpoint = torch.load(PATH)
model.load_state_dict(checkpoint['model_state_dict'])
scheduler.load_state_dict(checkpoint['scheduler_state_dict'])
optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
total_epoch = checkpoint['total_epoch']
epoch = checkpoint['epoch']
loss_train = checkpoint['loss_train']
loss_eval = checkpoint['loss_eval']

# 학습인지 추론인지에 따라 해당하는 것을 실행한다.
model.train()
# model.eval()

 딥러닝의 데이터 세트와 데이터 로더까지 checkpoint에 저장하면, 저장 파일 사이즈가 너무 커지므로 이것들은 별도로 저장한다.

# 데이터 세트, 데이터 로더의 저장
torch.save(trainset, './trainset.pt')
torch.save(trainloader, './dataloader.pt')

 로드할 때에는 다음과 같다.

trainset = torch.load('./trainset.pt')
trainloader = torch.load('./dataloader.pt')

scikit-learn이든 PyTorch이든 어떤 저장방법이든 좋다.

 

 

레벨3의 설명


3.1 명명방법3: 적당한 영어 단어와 품사로 책무를 알 수 있는 이름을 붙이기

 클래스, 메소드, 함수을 상세히 분할하면 분할 할수록, 그러한 명명이 굉장히 중요해진다. 이름을 보면, "어떠한 처리를 하고있는가? 즉, 어떤 책무를 부담하고 있으며, 어떤 input으로 어떤 output을 하는가?"를 알 수 있는 것이 이상적이다.

 그러나 데이터 사이언즈나 AI계의 경우 알고리즘 그 자체가 복잡하므로, 맨처음보는 사람이 코멘트없는 상태에서 클래스나 메소드 내용을 확인하는 것은 어렵다. 그러므로, 명명 방법은 가능한한, 의도를 전달하는 것을 목표로 한다.

 지켜야할 최저한의 룰은 다음과 같다.

[1] 클래스명, 변수명은 명사로 한다.

[2] 메소드, 함수의 이름은 동사로 시작한다.

[3] 멤버 변수의 boolean형의 경우, 아래와 같이 동사로 시작하는 것이 좋다.

(예) is_admin, has_item, can_drive 등

[4] codic들을 사용한다.

 

3.2 적절한 예외처리 구현하기

 에러(예외)의 try-catch는 굉장히 손이가는 부분으로 데이터 사이언티스트, AI 엔지니어에게는 굉장히 고통스럽다. Jupyter Notebook만으로 끝나는 프로젝트 규모라면 문제없지만, AI를 시스템에 구현하는 경우는 에러 핸들링이 중요하다. 예외가 발생햇을 때에 시스템 처리 전체가 멈춰버리기 때문이다.

 따라서, 구현 코드에 있어서 try가 톱 레벨에 있는 것 처럼(즉, 코드가 try:의 안에 없는 상태를 피하는 것 처럼)한다. 공식문서를 살펴보자.

예: 나눗셈할 함수를 정의하는 경우

def func_division(a, b):
    ret = a/b
    return ret

ans = func_division(10, 0)과 같은 숫자를 입력하면 에러가 발생하여 프로그램 자체가 멈추게 된다. 따라서, 아래와 같이 예외처리를 추가해준다.

def func_division(a, b):
   try:
      ret = a/b
      return ret
   except:
      print("예외가 발생했습니다.")

 그러나 이것만으로 충분하지 않다. 처리 내용에서 부터 발생한 예외를 예측할 수 있는 모든 경우를 고려해 제대로 에러를 핸들링하여 상응하는 예외처리를 해야한다.

def func_division(a, b):
   try:
      ret = a/b
      return ret
   except ZeroDivisionError as err:
      print('0나눗셈 에러가 발생했습니다.:', err)
   except:
      print("예기치 못한 에러가 발생했습니다.")

 아까 에러가 발생했던 ans = func_division(10, 0)을 다시 실행하면 0 나눗셈 에러가 발생했습니다 : division by zero가 출력된다. 또한 ans = func_division("hoge", "fuga")로 실행하면, 숫자가 아니므로 예기치 못한 에러가 발생했습니다라는 메시지가 출력하게 된다.

 

3.3 적절한 로그를 구현하기

 데이터 사이언티스트, AI 엔지니어의 경우, print문으로 출력하여 상태를 확인하는 것이 일반적이지만, 시스템에 합치는 경우 print문으로는 곤란하므로, 제대로 로그를 출력하도록 작성한다.

 로그의 사용법은 다음과 같은 느낌이다. 

import logging

logger = logging.getLogger(__name__)

# log에 넣을 같을 적절히 작성
total_epoch = 1000
epoch = 100
loss_train = 5.44444

# log에 기록할 내용
log_list = [total_epoch, epoch, loss_train]

# log에 기록
logger.info(
    "total_epoch: {0[0]}, epoch: {0[1]}, loss_train: {0[2]:.2f}".format(log_list)
)

# log에 기록했던 내용을 출력하여 확인(지금은 확인용으로, 미래에는 불필요)
print("total_epoch: {0[0]}, epoch: {0[1]}, loss_train: {0[2]:.2f}".format(log_list))

 이 경우, 로그된 내용을 print문으로 확인하면 다음과 같이 출력된다.

total_epoch: 1000, epoch: 100, loss_train: 5.44

 여기서 {0[2]:.2f}는 .format으로 받은 리스트의 두 번째 요소의 소수점 두번째까지 표시하는 것을 의미한다. logger이든 print문이든 Python으로 작성하는 방법은 여러가지 있지만, 작성하는 변수가 많은 경우에는 리스트를 작성하면 좋다.

 logger.info뿐만이 아니라, logger.debug, logger.warning, logger.error등 로그 레벨은 상황에 맞게 변경하자. 최소 위의 예 정도로 충분하지만, 로그의 세계는 깊고 넓다. 자세한 내용은 공식문서를 참고하길 바란다.

[참고] f-string을 사용한 예 (python 3.8 상정)

# log에 넣을 값을 적당히 작성
total_epoch = 1000
epoch = 100
loss_train = 5.44444

# log에 기록
logger.info(f"{total_epoch=}, {epoch=}, loss_train: {loss_train=:.2f}")

 

3.4 함수, 메소드의 인수는 3개 이하로하기

 함수, 메소드의 인수는 많아도 3개까지이다. 4개이상을 피하자. 인수가 많으면 그 함수의 사용법이 알기 어렵게 되고, 단체 테스트의 준비나 관리도 번거롭게 된다.

 많은 인수를 다루고 싶은 경우는 사전형 변수에 hogehoge_config 등으로 하여, 1개의 사전 변수를 변수에 전달하는 방법으로 한다.

예 : 복잡한 계산 함수

(예외처리를 작성하자고 위에서 말했지만, 여기서는 번거로우므로 생략)

def func_many_calculation(a, b, c, d, e):
    ret = a*b*c/d/e
    return ret

다음과 같이 정의하여 ans = func_many_calculation(10, 2, 3, 5, 2) 로 사용하면, 인수가 많아 다루기 불편하고 틀리는 경우가 많게 된다. 

 따라서,  다음과 같이 사전형으로 정의한다.

def func_many_calculation(func_config):
    a = func_config["a"]
    b = func_config["b"]
    c = func_config["c"]
    d = func_config["d"]
    e = func_config["e"]
    ret = a*b*c/d/e
    return ret

 func_config = {"a" : 10, "b":2, "c":3, "d":5, "e":2 }를 대입하는 변수를 사전형으로 작성해 ans = func_many_calculation(func_config)를 실행한다.

 그러나, 이러한 작성법은 함수의 정의가 더욱 불편해지므로, 함수 정의부분에서는 인수를 보통으로 작성하고, 실행하는 부분에서는 인수를 적게하는 것이 좋을 것이다.

def func_many_calculation(a, b, c, d, e):
    ret = a*b*c/d/e
    return ret
func_config = {"a": 10, "b": 2, "c": 3, "d": 5, "e": 2}
ans = func_many_calculation(**func_config)

 

3.5 *args, **kwargs를 적절히 사용하기

"*args, **kwargs가 뭔지 잘 모르겠지만, 논문 구현 리포지토리를 살펴보면, *args, **kwargs가 꽤 많이 보인다" 라고 생각하고 있는 사람들이 있을 것 같다. args는 arguments, kwargs는 keyword arguments의 약어이다.

 여기서 *는 리스트 변수의 언팩 조작이며, **는 사전형 변수의 언팩조작이다. 이 *, **을 함수의 인수로써 사용했을 경우 다음과 같이된다.

 예를 들어, 다음과 같이 함수를 정의한 경우에

def func_args_kwargs(*args, **kwargs):
    print(args)
    if len(args) >= 2:
        print(args[1])
    print(kwargs)
    flg_a = kwargs.pop("flg_a", False)
    print(flg_a)

 func_args_kwargs(10, 20)을 실행하면, 입력인수 10과 20이 args에 들어가, (10, 20) 20 {} False가 출력된다. 계속해서, 사전형 변수도 입력으로 사용해 func_args_kwargs(10, **{"flg_a": True})를 실행하면, 맨 처음 사전형 이외의 인수는 args에 들어가고 사전형은 kwargs에 들어가 (10, ) {'flg_a':True} True가 출력된다.

 *args, **kwargs는 가변길이 인수라고 불니다. 왜 이러한 가변길이 인수를 사용하는지 설명하도록 하겠다. 이러한 인수를 사용하는 이유는 크게 세 가지이다.

 첫 번째 이유는 함수의 인수에 여분의 무언가가 들어가도 실행이 가능하도록 하기 위함이다. 예를 들어, 아래의 함수를 실행하면,

def func_args_kwargs2(a, *args, **kwargs):
    print(a)

func_args_kwargs2(10, 20, 30)의 출력이 10이 되어, 에러가 발생하지 않고 실행된다. 

 두 번째 이유는 함수를 확장해서 인수를 나중에 늘리고 싶은 경우에 *args로 받으면, 함수의 안이나 인수 정의 부분을 수정할 필요없기 때문이다.

 세 번째 이유는 함수나 메소드를 실행할 때의 인수로써 optional로, 실행시에 전달해도 전달하지 않아도 괜찮아 인수를 받아들이는 부분으로 *args, **kwargs를 사용한다. 이 때에는 *args이나 **kwargs를 받을 것이 없는 경우를 대비해 기본 값을 설정해둔다. 예를 들면 다음과 같다.

def func_args_kwargs3(a, *args, **kwargs):
    b = kwargs.pop("b", 2.0)
    print(a*b)

 func_args_kwargs3(3.0)의 출력은 6.0이 된다. 함수내의 b는 기본적으로 2.0가 사용된다. 

 func_args_kwargs3(3, **{"b":4.0})의 경우, 출력은 12.0이 된다. 함수 내의 b는 인수로는 전달된 4.0이 된다.

 

 

레벨4의 설명


4.1 삼항연산자 if문은 짧게 쓰기

 Python에서는 if문을 삼항 연산자로 작성해, 1행으로 묶어서 쓰는 경우가 많다.  

# 짝수인지 홀수인지를 판정
num = 10

if num % 2 == 0:
    print("짝수")
else:
    print("홀수")

위 코드는 아래와 같이 바꿔쓰자.

# 짝수인지 홀수인지를 판별
num = 10

print("짝수") if num % 2 == 0 else print("홀수")

 

4.2 sklearn 준거로, 전처리 및 모델의 클래스를 구현하기

 sklearn준거이란 scikit-learn의 BaseEstimator, TransformerMixin, ClassifierMixin등을 계송하여 scikit-learn의 다른 오브젝트와 함께, scikit-learn의 Pipeline 클래스로 다뤄지도록 구현된 클래스이다. 

 자신이 만든 전처리 클래스나 모델을 sklearn준거하면 scikit-learn의 Pipeline에 합쳐서 사용할 수 있으므로 편리하다. 예를 들어, 전처리 클래스인 경우 다음과 같이 쓴다. TransformerMixin와 BaseEstimator을 계승한다.

from sklearn.base import BaseEstimator, ClassifierMixin, TransformerMixin
from sklearn.utils.validation import check_array, check_is_fitted, check_X_y


class TemplateTransformer(TransformerMixin, BaseEstimator):
    """ 전처리 클래스의 견본"""

    def __init__(self, demo_param='demo'):
        self.demo_param = demo_param  # 나중에 사용할 파라미터는 init로 준비

    def fit(self, X, y=None):
        """전처리에 필요한 학습 실시. y가 존재하지 않은 경우에도 y=None로 전달해준다"""

        X = check_array(X, accept_sparse=True)  # check_array는 sklearn의 입력 검증 함수

        # 무언가 학습하는 처리. 여기에서는 하나의 예로 n_features_이라는 파라미터를 학습하고 있다.
        self.n_features_ = X.shape[1]

        # 전처리의 Transformer그대로를 돌려준다
        return self

    def transform(self, X):
        """ 인수X에 전처리를 적용한다"""

        # 전처리를 적용할 때에는, 학습해둬야할 파라미터(여기서는n_features_)가 있는지를 확인
        check_is_fitted(self, 'n_features_')

        # sklearn의 입력검증
        X = check_array(X, accept_sparse=True)

        # 어떠한 변환처리
        X_transformed = hogehoge(X)

        return X_transformed

 또한, 모델 클래스의 경우는 ClassifierMixin, BaseEstimator를 계승시킨다. 아래는 교사가 있는 학습를 이미지하고 있지만, 교사가 없는 학습에서도 동일하게 작성한다.

from sklearn.base import BaseEstimator, ClassifierMixin, TransformerMixin
from sklearn.utils.validation import check_array, check_is_fitted, check_X_y


class TemplateClassifier(ClassifierMixin, BaseEstimator):
    """ 모델 클래스의 견본 """

    def __init__(self, demo_param="demo"):
        self.demo_param = demo_param  # 나중에 사용할 파라미터는 init를 준비

    def fit(self, X, y):
        """ 학습의 실시. 교사가 있는 학습등에서 y가 존재하지 않는 경우에도 y=None로 전달해둔다"""

        # sklearn의 입력 검증
        X, y = check_X_y(X, y)
        
        # 어떠한 학습
        self.fugafuga = piyopiyo(X, y)

        # 학습한 학습기(model) 그 자체를 돌려준다.
        return self

    def predict(self, X):
        """ 미지의 데이터 추론 """

        # 추론전에 학습해둬야할 파라미터(여기서는fugafuga)가 있는지 확인
        check_is_fitted(self, ["fugafuga"])

        # sklearn의 입력 검증
        X = check_array(X)

        # 추론한다.
        y_predicted = self.fugafuga(X)

        return y_predicted

 sklearn 준거에 구현할 때에는 scikit-leran으로 템플릿이 공개되어 있으므로, 그것을 베이스로 변경해나가는 것을 추천한다.  

 

4.3 데코레이터를 적절히 활용하기

 데코레이터는 @hogehoge같은 것이다. 자주 메소드명이나 함수명 위에 보인다. Python에는 표준의 데코레이터와 자체 제작 데코레이터이다. Python 표준의 데코레이터로 자주 보이는 것이 @property, @staticmethod, @classmethod, @abstractmethod이다. 하나 하나 확인해보자.

  @property는 클래스 외부에서부터 그 멤버 변수를 변경불가능하게 하는 것이다.

(예) @property

class User:
    def __init__(self, name: str, user_type: str):
        self.name = name
        self.__user_type = user_type

    @property
    def user_type(self):
        return self.__user_type

 User클래스의 user_type은 @property를 정의한다. 그러면 taro = User("taro", "admin"), print(taro.user_type)은 문제실행되어 admin이 출력된다. 그러나, taro.user_type = "normal"하면, @property로 정의한 멤버 변수로 바꾸려고하면 에러가 된다. 이와 같이 외부에서 바꿀 수 없는 변수를 정의할 수 있다.

(예) @staticmethod, @classmethod

class User:
    def __init__(self, name: str, user_type: str):
        self.name = name
        self.user_type = user_type

    @staticmethod
    def say_hello(name):
        print("Hello " + name)

 @staticmethod, @classmethod는 클래스를 오브젝트로써 실체화하지 않아도 그 메소드를 사용가능하도록 한다. 위와 같이 정의하고 아래와 같이 실행하면, Hello Hanako를 출력된다.

User.say_hello("Hanako")

 다른 표준 Python 데코레이터는 @abstractmethod이 있다. 클래스의 메소드에 그 @abstractmethod가 붙어 있는 경우, 그 클래스를 계승한 자식 클래스에서는 그 메소드를 반드시 구현해야한다. 구현하지 않으면 에러가 발생한다. 즉 추상 클래스를 정의하고, 계승한 자식 클래스로 메소드의 정의를 강제할 때에는 @abstractmethod를 사용한다.   

 계속해서 자체 제작한 데코레이터에서 설명하도록 하겠다. User 클래스에서 user_type이 admin의 경우만 실행할 수 있는 처리를 구현하려고 한다. 

class User:
    def __init__(self, name: str, user_type: str):
        self.name = name
        self.user_type = user_type

    def func_admin_can_do(self):
        if self.user_type=="admin":
          # 어떠한 admin만 사용할 수 있는 처리
          print("I'm admin.")
        else:
          print("cannot do this func with auth error.")

 이렇게 작성해도 괜찮지만, user_type이 admin의 경우만 실행할 수 있는 처리가 이 외에 또 뭐가 많이 있다면, 그 때마다 if문으로 체크하는 것이 번거롭다. 여기서 데코레이터를 사용하면 아래와 같이 된다.

def admin_only(func):
    """데코레이터의 정의"""
    def wrapper(self, *args, **kwargs):
        if self.user_type == "admin":
            # admin만 할 수 있는 처리
            return func(self, *args, **kwargs)
        else:
            print("cannot do this func with auth error.")

    return wrapper


class User:
    def __init__(self, name: str, user_type: str):
        self.name = name
        self.user_type = user_type

    @admin_only
    def func_admin_can_do(self):
        # 어떠한 admin만 할 수 있는 처리
        print("Im admin.")

 이렇게 정의하면 @admin_only이라는 데코레이터를 붙이는 것으로 유저가 admin인 경우에만 처리가 실행된다. admin인지 아닌지를 판정하는 처리를 하는 메소드가 많은 등 동일한 것을 몇 번이고 작성해야할 경우에 데코레이터가 유용하다.

 

4.4 팀 개발용으로 에티터 설정을 통일하기

 코딩시에 오토포맷을 사용하면, 자동 정형화되므로 편리하다. Python이면 black을 최근 많이 사용된다. 그러나 팀 멤버가 각각 다른 포맷을 사용하고 있으면 포맷의 변경만으로 파일을 덮어써야하는 것이 늘어나 git commit의 내용이 복잡해진다.

 따라서, 팀으로 개발할 때에는 오토포맷을 통리하자. 예를 들어, 리포지토리의 바로 아래에 폴더 ".vscode"를 만들고, 그 안에 파일 "settings.json"을 넣어둔다. settings.json에는 "python.formatting.provider" : "black"적어둬, black으로 포맷된다.

 멤버가 코딩할 때에 VS code에서 그 포맷이 실행되면, 리포지토리의 .vscode의 settings.json의 설정이 반영되므로 모든 멤버가 동일한 코딩 스타일로 통일된다. 구체적인 Python용의 VS code의 설정은 다음 기회에 다루도록 하겠다.

 

4.5 GitHub의 풀리퀘스트용 template를 준비하여 주의점을 기재해두기

 GitHub의 풀리퀘스트용 template를 준비해두자. 리포지토리의 바로 아래 폴더에 ".github"를 만들고, 그 안에 파일 "PULL_REQUEST_TEMPLATE.md"를 생성한다. 이 PULL_REQUEST_TEMPLATE.md가 풀리퀘스트시의 투고 내용의 템플릿으로 표시된다. 


참고자료

https://qiita.com/sugulu_Ogawa_ISID/items/c0e8a5e6b177bfe05e99

728x90