by Yngie

합성곱 신경망(CNN)을 활용한 문장 분류

|

본 포스트의 내용은 고려대학교 강필성 교수님의 강의김기현의 자연어처리 딥러닝 캠프 , 밑바닥에서 시작하는 딥러닝 2 , 한국어 임베딩 책을 참고하였습니다.

CNN for Sentence Classification

자연어는 시퀀스 데이터이므로 일반적으로 합성곱 신경망(Convolutional Neural Network, CNN)보다는 순환 신경망(Recurrent Neural Network, RNN)을 사용합니다. 시퀀스 데이터만이 가지고 있는 순서를 잘 살릴 수 있는 신경망 구조이기 때문이지요. 하지만 자연어 데이터에서 합성곱 신경망이 아주 쓰이지 않는 것은 아닙니다. 이번 시간에는 합성곱 신경망을 사용하여 문서를 분류하는 방법에 대해서 알아보도록 하겠습니다.

CNN for Sentence Classification

2014년, Yoon Kim은 Convolutional Neural Networks for Sentence Classification 논문을 통해 합성곱 신경망을 사용하여 문서를 분류하는 방법을 제안하였습니다. 논문에서 발표한 모델의 구조는 다음과 같습니다.

cnn1

이미지 출처 : Convolutional Neural Networks for Sentence Classification

위 모델의 입력 데이터는 문장을 행렬로 표현한 $M_{n \times k}$ 입니다. 이 행렬에서 각 행은 문장 내 단어를 $k$ 차원의 벡터로 임베딩한 것입니다. 이를 $n$ 개씩 이어붙여 입력 행렬을 만듭니다. 문장 내에 있는 단어의 수가 $n$ 보다 작을 경우에는 패딩(Zero-padding)을 넣어주며, 클 경우에는 길이에 맞춰 자르도록 합니다.

Vector

위 모델에 사용되는 임베딩 벡터는 Pre-trained 단어 임베딩 벡터를 사용합니다. 합성곱 신경망을 통해 학습하는 과정에서 임베딩 벡터가 고정된 값을 가지도록 하는 Static(정적) 학습 방식과 학습하면서 값이 변하는 Non-static(동적) 방식이 있습니다. 또 여러 형태의 임베딩 벡터를 혼합하여 사용하는 Multi-channel 방법을 적용할 수도 있습니다. 이미지를 합성곱 신경망을 통해서 학습할 때, RGB 값을 각각 처리하기 위해서 3개의 채널을 사용하는 것과 동일한 구조를 가지고 있습니다. 예를 들어, 문서 분류를 위해 3개의 채널을 구성한다면 Word2Vec(Static), GloVe(Static), Word2Vec(Non-static) 처럼 구성할 수 있습니다.

1-D Conv

합성곱 신경망을 통해서 이미지를 처리할 때에는 주로 2차원 합성곱(2-D Conv)을 적용합니다. 학습의 대상이 되는 각 픽셀이 평면상에 있는 위치 정보를 가지고 있기 때문입니다. 그래서 필터가 가로 방향으로 슬라이딩하며 합성곱 연산을 수행하면서 가로 방향에 대한 위치정보를 학습할 수 있게 되는 것이지요.

하지만 문서 분류에 합성곱 신경망을 적용할 때 문장을 나타내는 입력 행렬에서 가로 방향을 단어 하나를 임베딩한 벡터입니다. 다시 말해 특정 행 내에서는 요소간에 위치적인 차이가 없다는 것이지요. 이 때문에 문장을 처리하는 합성곱 신경망에서는 세로 방향으로만 슬라이딩하는 1차원 합성곱(1-D Conv)이 적용됩니다. 입력 행렬의 크기가 $n \times k$ 이므로 Window는 가로의 길이가 $k$ 인 직사각형입니다. 세로 크기는 조정할 수 있으며 이 값이 커질수록 더 많은 단어를 고려할 수 있게 됩니다. 스트라이드(Stride)는 주로 1을 사용합니다.

Pooling

합성곱 과정이 끝날 때마다 각 벡터를 하나의 스칼라 값으로 바꾸기 위한 풀링(Pooling)을 수행합니다. 논문에서는 최댓값 풀링(Max Pooling)을 사용했을 때가 평균값 풀링(Average Pooling)을 사용했을 때보다 더 좋은 결과를 보여줌을 말하고 있습니다. 해당 문서에서 중점적으로 보아야 하는 부분에 집중하는 것이 모든 단어를 고려하는 것보다 더 좋다고 할 수 있겠습니다. 풀링이 끝나면 각 클래스로 분류를 수행하기 위한 완전 연결 층(Fully-Connected Layer)으로 연산을 이어나가게 됩니다.

Training

논문에서는 성능을 높이기 위해서 Window의 세로 크기가 $3,4,5$ 인 특성 맵을 각각 100개씩 사용하였습니다. 과적합 방지를 위해서 드롭아웃(Dropout)을 마지막 완전 연결층에 $0.5$ 로 적용하고, 가중치에는 L2 정규화(Regularization)을 적용하였습니다. 미니 배치 사이즈는 $50$으로 하였습니다. 위 값은 SST-2에서 그리드 서치(Grid Search)를 적용하여 찾아낸 것입니다.

Evaluation

아래는 위 모델을 각각의 데이터셋에 적용한 후에 이전 모델과의 성능을 비교한 표입니다. 우선 위 모델에 4가지 학습 방법을 적용했을 때의 성능을 비교하고 있습니다. CNN-rand는 임베딩 벡터에 랜덤한 값을 부여한 뒤에 학습 과정에서 학습시킨 모델입니다. CNN-static과 CNN-nonstatic은 Pre-trained 단어 임베딩 벡터를 사용하되 학습 과정에서 변하지 않도록 할 지, 변하도록 할 지를 다르게 설정한 모델입니다. CNN-multichannel은 여러 Pre-trained 단어 임베딩 벡터를 설정하여 학습한 것입니다.

cnn2

이미지 출처 : Convolutional Neural Networks for Sentence Classification

결과에서 주목할 점은 static이든 non-static이든 multichannel이든 Pre-trained 임베딩 벡터를 사용하는 것이 랜덤 임베딩 벡터로 학습하는 것보다 결과가 좋다는 것입니다. 여러 가지 임베딩 벡터를 혼합한 multichannel의 성능이 가장 높을 것으로 예측해볼 수 있지만 실제로는 데이터셋마다 최고 성능을 보이는 모델이 다른 것을 알 수 있습니다. 기존 모델과 비교해 보았을 때에는 7개의 데이터셋 중 4개의 데이터셋에서 합성곱 신경망을 사용한 모델이 가장 좋은 성능을 보였음을 알 수 있습니다.

Character-Level CNN

1년이 지난 2015년에는 Character-level Convolutional Networks for Text Classification 논문을 통해 Character 단위에서 합성곱을 수행하는 Character-Level CNN이 고안되었습니다. 전체적인 모델의 구조는 아래와 같습니다.

cnn3

이미지 출처 : Character-level Convolutional Networks for Text Classification

위 모델에서는 총 70개의 Character를 수량화하여 나타내며 이는 26개의 알파벳과 10개의 숫자, 33개의 특수문자와 1개의 공백 문자로 구성되어 있습니다.

Vector

Character-Level CNN에서 문장이 행렬로 표현되는 방식은 문장을 단어 단위 벡터의 행렬로 나타내었던 방식과 다릅니다. 후자가 바로 임베딩 벡터를 적용하였다면, 전자는 우선 각 Character를 70차원의 원-핫 인코딩 벡터(One-hot Encoding Vector)로 표현한 뒤에

Large Feature의 경우 1024 길이를 사용하므로 행렬의 크기는 70*1024가 된다.

Conv & Pooling

이 모델에는 총 6개의 합성곱 층이 사용되며 앞단에 있는 4개의 합성곱 층에는 Window 사이즈가 7인 커널을 사용하고 나머지 2개 층에서는 Window 사이즈가 3인 커널을 사용합니다. 그리고 합성곱 층 다음마다 풀링을 모두 적용하는 것이 아니라 적용하는 층이 있고 그렇지 않은 층이 있습니다. 풀링을 적용하는 층에는 Window 사이즈가 3인 최댓값 풀링을 사용합니다. 6개의 층을 모두 지난 이후에는 2개의 완전 연결층으로 넘어가게 됩니다.

Data Augmentation

논문에서는 데이터 증식(Data augmentation)에 대한 방법도 제시하고 있습니다. 이미지를 처리할 때에는 이를 회전시키거나 좌우 혹은 상하 반전시켜 데이터를 증식하는 등 여러 방법을 적용할 수 있습니다. 하지만 텍스트의 경우 이런 방법을 적용할 수 없기 때문에 새로운 방식을 사용합니다. 논문에서는 동의어(Synonym)로 치환하여 데이터를 늘리는 방법을 사용하였습니다.

Evaluation

아래는 문서 분류에 대한 Character-Level CNN과 기본 모델의 성능을 비교한 표입니다.

cnn4

이미지 출처 : Character-level Convolutional Networks for Text Classification

크기가 작은 데이터셋에서는 TF-IDF가 가장 좋은 성능을 보이며 크기가 일정 이상으로 커졌을 때에는 합성곱 신경망을 사용한 모델의 성능이 확연히 높아지는 것을 볼 수 있습니다.

Addition

How deep is it?

2017년에는 합성곱 신경망의 깊이(Depth)가 문장 분류에 있어서 어떤 영향을 끼치는 지를 연구한 논문인 Do Convolutional Networks need to be Deep for Text Classification? 이 발표되었습니다. 논문에서는 아래 그림과 같은 2개의 합성곱 신경망 모델을 비교하고 있습니다. 왼쪽은 첫 번째로 알아본 것과 같이 합성곱 층이 얇은 모델을 나타냅니다. 오른쪽은 2016년 Densely Connected Convolutional Networks 논문에서 발표된 DenseNet과 같이 합성곱 층을 깊게 쌓은 모델을 나타냅니다.

cnn5

이미지 출처 : github.com/pilsung-kang

논문에서는 이 둘을 비교하며 Character-Level 에서는 합성곱 층을 깊게하는 것이 성능이 좋고, 단어 단위로 벡터를 구성할 때에는 합성곱 층을 많이 쌓지 않는 것이 좋다고 발표하였습니다.

Localization

18년도에는 말뭉치의 감성을 분석한 뒤 CAM(Class Activation Map)을 활용하여 시각화한 논문이 발표되었습니다. 해당 논문에서는 단어 단위의 CNN을 사용하였습니다. 문장 앞뒤로 패딩을 준 뒤에 Window 사이즈를 $3,4,5$ 로 다르게 설정하고 에버리지 풀링(Average pooling)을 적용하였습니다. 학습 후 얻어낸 가중치를 통해, 어떤 단어를 보고 긍/부정을 판별하는 지를 시각화하였습니다. 아래는 해당 논문에서 IMDB 데이터셋을 시각화한 이미지입니다. 위는 Positive로 분류된 문서이며, 아래는 Negative로 분류된 문서입니다. 이렇게 분류되는데에 어떤 단어가 결정적인 영향을 미쳤는지에 대해서 잘 보여주고 있습니다.

cnn7

이미지 출처 : github.com/pilsung-kang

Comment  Read more

클로저 (Closure)

|

아래 내용은 파이썬 코딩도장 , 스쿨오브웹 , 헥사브레인 을 참조하여 작성하였습니다.

클로저

위키피디아에서 언급하고 있는 클로저의 정의는 다음과 같다.

컴퓨터 언어에서 클로저(Closure)는 일급 객체 함수(first-class functions)의 개념을 이용하여 스코프(scope)에 묶인 변수를 바인딩 하기 위한 일종의 기술이다. 기능상으로, 클로저는 함수를 저장한 레코드(record)이며, 스코프(scope)의 인수(Factor)들은 클로저가 만들어질 때 정의(define)되며, 스코프 내의 영역이 소멸(remove)되었어도 그에 대한 접근(access)은 독립된 복사본인 클로저를 통해 이루어질 수 있다. - 위키피디아 : 클로저

클로저를 알아보기 전에 먼저 일급 객체란 무엇인지에 대해 알아본 후, 클로저가 어떤 방식으로 사용되는 지 알아보자.

일급 객체

파이썬은 ‘모든 것이 객체(object)다’ 라는 철학을 가지고 있는 언어이다. 이 객체에는 숫자, 문자열, 리스트, 튜플, 딕셔너리 등이 있다. 파이썬은 함수도 이들과 같은 일급 객체(First-class citizen) 로 취급한다. 일급 객체란 다른 객체들에 일반적으로 적용 가능한 연산을 모두 지원하는 객체를 가리키며 일급 객체는 다음과 같은 세 가지 조건을 만족한다. 첫 번째로 변수나 데이터 구조 안에 담을 수 있고, 두 번째로 매개변수로 전달이 가능하며, 마지막으로 리턴값으로 반환할 수 있다. 아래의 코드를 보며 파이썬에서 함수가 일급 객체로서 어떻게 사용될 수 있는지 알아보도록 하자.

def answer():
    print(42)
    
answer()

>>> 42

아래와 같이 새로운 함수를 정의할 때 answer() 함수를 인자로 집어넣어 사용할 수 있다.

def run_something(func):
    func()
    
run_something(answer)

>>> 42

아래는 일급 객체 함수의 성질을 이용하여 사칙 연산 함수를 반복문으로 구현한 것이다.

def add(a,b):
    return a + b
def substract(a,b):
    return a - b
def multiply(a,b):
    return a * b
def divide(a,b):
    return a / b

func_list = [add, substract, multiply, divide]
a = int(input("a의 값을 입력하세요 : "))
b = int(input("b의 값을 입력하세요 : "))
for func in func_list:
    print(func.__name__, ":", func(a,b))

>>> a의 값을 입력하세요 : 3
    b의 값을 입력하세요 : 4
    add : 7
    substract : -1
    multiply : 12
    divide : 0.75

위 코드에서 각 사칙연산 함수는 리스트 구조 내에 요소로 담겼으며 이를 for 반복문을 통해서 한 번에 출력하는 것을 볼 수 있다. 파이썬에서는 위와 같이 함수가 일급 객체로서 작동하므로 변수나 데이터 구조 안에 담거나, 매개 변수로 전달이 가능하거나 리턴값으로 반환할 수 있다. 이런 특징은 아래에서 클로저 개념을 설명하는데 사용되며, 이외에도 파이썬에서 다양하게 많이 쓰이므로 알아두면 좋다.

클로저

이제 일급 객체에 대해 이해했으니 클로저(Closure) 에 대해 알아보도록 하자. 클로저가 어떻게 사용되는지 아래의 코드를 보며 알아보자.

def outer_func():
    message = 'Hi'

    def inner_func():
        print(message)

    return inner_func()

outer_func()

>>> Hi

위 코드에서 “Hi” 가 출력되는 과정은 다음과 같다. outer_func() 를 호출하면 첫 번째 줄에서 정의된 함수가 호출된다. 정의된 함수에서 가장 먼저 message 변수에 문자열 "Hi" 를 할당한다. 그리고 내부에 있는 함수인 inner_func() 를 정의하고 return 하면서 inner_func() 를 호출한다. 호출된 내부 함수에서는 저장된 message 변수를 출력하므로 이 값이 최종적으로 출력된다. 이 코드를 변형시키면 어떻게 될까?

def outer_func():
    message = 'Hi'

    def inner_func():
        print(message)

    return inner_func	#위 코드에서 ()를 제거하였다.

outer_func()

>>>

이 코드를 실행할 경우 출력값이 없는 것을 볼 수 있다. inner_func() 를 호출하지 않고 inner_func 함수 오브젝트만을 리턴하였기 때문이다. 아래의 코드처럼 오브젝트를 다른 변수에 할당한 후 변수를 출력하면 함수 inner_func 함수 오브젝트만을 리턴하는 것을 알 수 있다.

def outer_func():
    message = 'Hi'

    def inner_func():
        print(message)

    return inner_func	#위 코드에서 ()를 제거하였다.

my_func = outer_func()

print(my_func)

>>>
<function outer_func.<locals>.inner_func at 0x7f0288608680>

이 변수를 이용해서 inner_func 를 호출하는 코드를 작성해보자.

def outer_func():
    message = 'Hi'

    def inner_func():
        print message

    return inner_func

my_func = outer_func()

my_func()

>>> Hi

inner_func 가 할당되어 있는 변수 my_func 를 통해서 “Hi” 라는 결과물을 출력할 수 있었다. 하지만 이 “Hi”outer_func 함수 내에서 message 변수에 할당된 문자열이었다. 어떻게 inner_func 가 자신 밖에 있는 message 를 참조할 수 있었을까?

def outer_func():
    message = 'Hi'

    def inner_func():
        print(message)

    return inner_func

my_func = outer_func()

print(my_func)
print(dir(my_func))
print(type(my_func.__closure__))
print(my_func.__closure__)
print(my_func.__closure__[0])
print(dir(my_func.__closure__[0]))
print(my_func.__closure__[0].cell_contents)

>>>>
<function outer_func.<locals>.inner_func at 0x7f02886085f0>

['__annotations__', '__call__', '__class__', '__closure__', '__code__', '__defaults__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__get__', '__getattribute__', '__globals__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__kwdefaults__', '__le__', '__lt__', '__module__', '__name__', '__ne__', '__new__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__']

<class 'tuple'>

(<cell at 0x7f02885a24d0: str object at 0x7f028860d730>,)

<cell at 0x7f02885a24d0: str object at 0x7f028860d730>

['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'cell_contents']

Hi

위 코드에서 dir() 함수는 인자로 전ㄷ라받은 객체가 어떤 변수와 메서드를 갖고 있는지 나열하는 함수이다. 이 중 클로저라는 메서드를 사용했을 때의 타입과 그 값을 출력한다. 출력값을 통해 클로저 메서드는 튜플이고 그 값은 (<cell at 0x7f02885a24d0: str object at 0x7f028860d730>,) 임을 알 수 있다. 이 클로저에 어떤 값이 담겨 있는 지를 알아보기 위해 다시 cell_contents 메서드를 적용한 뒤 그 값을 출력하면 “Hi” 가 나온다. 이를 통해 외부에서 정의된 매개변수인 “Hi”inner_func() 에 어떻게 전달되는지를 알 수 있다.

이런 클로저의 성질을 다양하게 이용하면 하나의 함수만으로 다양한 결과물을 출력할 수 있다. 아래의 코드는 클로저를 활용하여 두 가지 결과를 만들어내는 파이썬 코드이다.

def outer_func(tag):
    tag = tag

    def inner_func(txt):
        text = txt
        print('<{0}>{1}<{0}>'.format(tag, text))

    return inner_func

h1_func = outer_func('h1')
p_func = outer_func('p')

h1_func('h1태그의 안입니다.')
p_func('p태그의 안입니다.')

>>> <h1>h1태그의 안입니다.<h1>
    <p>p태그의 안입니다.<p>

Comment  Read more

문서 분류 - Vector Space Model

|

본 포스트의 내용은 고려대학교 강필성 교수님의 강의김기현의 자연어처리 딥러닝 캠프 , 밑바닥에서 시작하는 딥러닝 2 , 한국어 임베딩 책을 참고하였습니다.

문서 분류

문서 분류(Document Classification/Categorization) 는 감성분석, 스팸 분류, 저자별 분류 등 다양한 Task에 사용되는 기술이다.

문서 분류는 대표적인 지도학습(Supervised Learning) 기반의 머신러닝 기법이다. 학습 데이터셋(Train set)의 특성(Feature) $X$ 으로는 각각의 문서가 들어가며 레이블(Label) $Y$ 로는 각 문서가 속한 범주(Category)가 입력된다. 그리고 테스트 데이터셋(Test set)에서 주어진 특성을 기준으로 레이블을 판별하는 정도를 평가하게 된다.

이를 학습하기 위한 알고리즘은 여러가지가 있다. 아래를 보면 어떤 문서 집합에 대해서 문서를 이진 분류(Binary Classification)했을 때의 결정 경계를 나타낸 것이다.

nlp_dc

이미지 출처 : Text-Analytics Github

이렇게 많은 알고리즘이 존재하는 이유는 모든 데이터셋에서 최고의 성능을 보이는 알고리즘이 없기 때문이다. 데이터셋이 달라짐에 따라 성능이 높은 학습 알고리즘이 다르므로 여러 알고리즘을 알고 있어야 한다.

벡터 스페이스 모델

벡터 공간 모델(Vector Space Model) 이란 하나의 문서를 DTM(Document-Term Matrix)을 사용하여 하나의 벡터로 표현한 것이다.

nlp_dtm

이미지 출처 : Text-Analytics Github

Naive Bayes Classifier

문서 분류를 위한 여러가지 알고리즘 중 하나인 나이브 베이즈 분류기(Naive Bayes Classifier) 에 대해 알아보자. 나이브 베이즈 분류기는 베이즈 규칙(Bayes’ Rule)에 가정(Naive Assumption)을 더한 것이다. 먼저 베이즈 규칙에 대해 알아보자. 베이즈 규칙을 수식으로 나타내면 다음과 같다.

[P(C_i x_1, x_2) = \frac{P(x_1, x_2 C_i) \cdot P(C_i)}{P(x_1, x_2)}]

특성 $x_1, x_2$ 가 주어졌을 때 문서의 범주가 $C_i$ 일 확률을 다음과 같이 나타낼 수 있다. 여기에 각각의 특성(단어)이 독립이라는 가정을 더하면 결합 확률을 하나씩 나누어 나타낼 수 있다.

[P(C_i x_1, x_2) = \frac{P(x_1, x_2 C_i) \cdot P(C_i)}{P(x_1, x_2)} = \frac{P(x_1 C_i) \cdot P(x_2 C_i) \cdot P(C_i)}{P(x_1, x_2)}]

위 수식에서 $P(C_i)$ 로 나타나는 것은 사전확률(Priority probability)이며 $P(x_1 \vert C_i), P(x_2 \vert C_i)$ 는 클래스가 주어졌을 때 각 특성이 등장할 조건부 확률이다. 각 부분을 구하는 방법은 다음과 같다.

[\hat{P}(c_j) = \frac{N.Doc(C = c_j)}{\text{Total number of documents}}
\hat{P}(w_i|c_j) = \frac{\text{count}(w_i,c_j)}{\sum_{w \in V} \text{count}(w,c_j)} \]

사전 확률은 특정 클래스에 속하는 문서의 개수를 전체 문서의 개수로 나누어 구할 수 있고. 조건부 확률은 특정 클래스의 문서에서 특정 단어가 등장한 횟수를 모든 단어가 등장한 횟수로 나누어 구할 수 있다.

이 방법은 한 가지 단점을 가지고 있다. 특정 클래스의 문서에 한 번도 등장하지 않은 단어가 있을 경우 그 단어의 조건부 확률은 0이 된다. 예를 들어 “negative” 클래스로 분류된 모든 문서에 “fantastic” 이라는 단어가 하나도 포함되어 있지 않을 경우를 생각해보자. 이 때의 조건부 확률은 아래와 같이 나타난다.

[\text{count}(\text{fantastic},\text{negative}) = 0 \quad 이면
\hat{P}(\text{fantastic}|\text{negative}) = 0 \quad 이므로
\hat{P}(\text{negative}|\cdots, \text{fantastic}, \cdots) = 0]

이러한 문제를 해결하기 위해 스무딩(Smoothing)이라는 기법이 고안되었다. 가장 기본적인 스무딩방법인 라플라스 스무딩의 경우 모든 단어의 발생 횟수에 1을 더해준다. 이러한 방법을 통해 특정 클래스에 한 번도 등장하지 않는 단어가 있더라도 확률이 0이 되는 것을 막는다. 라플라스 스무딩을 수식으로 나타내면 아래와 같다.

[\hat{P}(w_i c_j) = \frac{\text{count}(w_i,c_j)+1}{\sum_{w \in V} (\text{count}(w,c_j)+1)} = \frac{\text{count}(w_i,c_j)+1}{\sum_{w \in V} \text{count}(w,c_j)+V}]

kNN Classifier

이번에는 문서 분류를 위한 간단한 알고리즘 중 k-Nearest Neighbor Classifier(kNN Classifier) 에 대해 알아보자. kNN 알고리즘의 기본적인 아이디어는 ‘유유상종’이라는 사자성어에 근거한다. 우리가 분류해야 할 문서와 가장 유사한 k개의 문서가 어떤 클래스인지를 보고 가장 유사한 클래스로 분류하게 된다. 아래의 사진은 $k=3$ 일 때 문서를 분류하는 방법을 나타낸 것이다.

nlp_knn

이미지 출처 : Text-Analytics Github

kNN 알고리즘을 적용하는 방법은 다음과 같다. 먼저, 레퍼런스(위 그림에서 파란색 원, 주황색 사각형에 해당하는) 데이터를 준비한다. 두 번째로, 유사도에 대한 수치를 정의한다. 유사도는 거리에 반비례 하므로 어떤 거리(Norm)를 사용할 것인지를 결정하는 단계가 된다. 일반적으로는 L2 norm인 Euclidean Norm을 많이 사용하며 경우에 따라 L1 Norm등 다양한 거리를 사용하기도 한다. 세 번째는 몇 개의 레퍼런스를 찾을 것인지에 대한 k 를 설정한다. k 가 너무 작을 경우 지역 특성에 너무 민감하게 반응하여 과적합되는 경향이 있고, 반대로 k 가 너무 크면 지역 특성을 무시해버려 과소적합되는 경향을 보인다. 아래는 임의의 데이터셋에 대해 각각 k가 1, 10, 20, 50일 때에 대한 결정 경계를 나타낸 것이다.

nlp_knn_k

이미지 출처 : Text-Analytics Github

마지막으로 레이블을 판단하기 위한 기준을 정한다. 기준은 다수결 방법(Majority Voting)과 가중치 방법(Weighted Voting)이 있다. 다수결 방법은 선별한 k개의 문서 중에 어떤 클래스의 문서가 더 많은지를 보고 해당 문서의 클래스를 정한다. 가중치 방법은 가장 유사도가 높은(거리가 가까운) 레퍼런스에 가중치를 부여한다. 레퍼런스와의 유사도를 총 합이 1이 되도록 바꿔준 후 각 클래스 끼리 더하여 값이 더 높은 클래스로 분류하게 된다. 아래 이미지는 새로운 문서 X를 k=5일 때 다수결 방법과 가중치 방법을 활용하여 나누어준 것이다.

nlp_knn_vote

이미지 출처 : Text-Analytics Github

위 그림에서 다수결 방법을 사용할 경우에는 5개의 레퍼런스 중 ‘JoF’ 로 분류된 것이 더 많으므로 새로운 데이터는 ‘JoF’ 로 분류된다. 하지만 가중치 방법을 사용할 경우에는 각 클래스의 가중치가 ‘TPAMI(0.59), JoF(0.41)’ 로 값이 더 높은 TPAMI 로 분류된다.

kNN 알고리즘을 사용할 때에도 몇 가지 신경써야할 사항이 있다. 첫 번째는 Normalization이다. 각 특성이 단어인 경우에는 크게 상관이 없지만, 키-몸무게-성별 등 다양한 단위를 가지는 경우에는 정규화(Normalization)를 해주어야 거리를 올바르게 잴 수 있다.

두 번째로는 한 쪽으로 치우쳐진(Stratified) 데이터셋에 대해 컷오프(Cut-off)를 적용하는 방식이다. 클래스 A가 100명, 클래스 B가 400명으로 이루어진 전체 500명에 대한 데이터셋이 있다고 해보자. 새로운 데이터에 대해 다수결 방법을 사용하여 클래스 A일 확률이 $P(X=A) = 0.4$ 가 나왔다고 한다. 컷오프를 사용하지 않았다면 $P(X=B) = 0.6$ 이므로 새로운 데이터는 B클래스로 분류될 것이다. 하지만, 위와 같이 데이터가 한 쪽으로 치우쳐져 있을 때는 각 클래스에 대한 기댓값을 구하여 Threshold를 조정해준다. 해당 데이터셋에서 A 클래스의 기댓값이 $E(A) = 0.2$ 이고 $P(X=A) = 0.4$ 가 기댓값보다 높으므로 A클래스로 분류하게 된다.

Comment  Read more

제너레이터 (Generator)

|

아래 내용은 스쿨오브웹-제너레이터처음 시작하는 파이썬 , 파이썬 코딩의 기술 을 참조하여 작성하였습니다.

제너레이터

제너레이터(Generator) 는 파이썬의 시퀀스를 생성하는 객체(object)다. 제너레이터의 장점은 전체 시퀀스를 한 번에 처리하지 않고 하나하나씩 처리한다는데 있다. 그렇기 때문에 시퀀스의 크기가 매우 커진다고 해도 메모리 부담 없이 작업을 수행할 수 있다.

제너레이터 함수

일반적으로 함수에서 일련의 결과를 생성할 때 선택하는 가장 간단한 방법은 리스트를 반환하는 것이다. 정수 요소로 이루어진 리스트를 입력받아 각 요소를 세제곱한 값으로 이루어진 리스트를 반환하는 함수가 있다고 하자. 해당 함수를 파이썬 코드로 구현하면 아래와 같이 쓸 수 있다.

def cubic_num(num_lst):
    result = []
    for i in num_lst:
        result.append(i ** 3)
    return result

my_nums = [1,2,3,4,5]
my_cubic_num = cubic_num(my_nums)
print(my_cubic_num)

>>> [1, 8, 27, 64, 125]

위와 같이 일련의 시퀀스를 다루는 작업은 제너레이터를 사용해서 구현할 수도 있다. 위 코드를 제너레이터 함수로 구현하면 다음과 같다. 제너레이터 함수는 return 대신에 yield 표현식을 사용한다.

def cubic_num_gen(num_lst):
    for i in num_lst:
        yield i ** 3

my_nums = [1,2,3,4,5]
my_cubic_num_gen = cubic_num_gen(my_nums)
print(my_cubic_num_gen)

>>> <generator object cubic_num_gen at 0x7f0c50cf2f50>

동일한 작업을 수행하는 함수임에도 이상한 결과가 출력되는 것을 알 수 있다. 제너레이터 함수는 호출되면 실제로 실행하지 않고 바로 이터레이터(iterator)를 반환한다. 이터레이터는 내장 함수 next() 를 호출할 때 다음 값을 생성해내는 상태를 가진 헬퍼 객체이다. 내장 함수 next() 는 제너레이터가 다음 yield 표현식으로 진행할 수 있도록 한다. 그리고 제너레이터가 yield 에 전달한 값을 이터레이터가 호출하는 쪽에 반환한다.

def cubic_num_gen(num_lst):
    for i in num_lst:
        yield i ** 3

my_nums = [1,2,3,4,5]
my_cubic_num_gen = cubic_num_gen(my_nums)

print(next(my_cubic_num_gen))

>>> 1

한 번에 하나의 작업만을 수행하는 제너레이터 특성상 첫 번째 요소에 대해서만 진행된 것을 볼 수 있다. next() 함수를 반복적으로 사용하여 값을 출력해보자. 입력 리스트의 크기가 5이므로 5번 사용하면 모든 값을 출력할 수 있다.

def cubic_num_gen(num_lst):
    for i in num_lst:
        yield i ** 3

my_nums = [1,2,3,4,5]
my_cubic_num_gen = cubic_num_gen(my_nums)

print(next(my_cubic_num_gen))
print(next(my_cubic_num_gen))
print(next(my_cubic_num_gen))
print(next(my_cubic_num_gen))
print(next(my_cubic_num_gen))

>>> 1
    8
    27
    64
    125

요소의 개수보다 더 많은 next() 함수를 호출할 경우에는 더 이상 진행할 작업이 없으므로 StopIteration 예외가 발생한다.

def cubic_num_gen(num_lst):
    for i in num_lst:
        yield i ** 3

my_nums = [1,2,3,4,5]
my_cubic_num_gen = cubic_num_gen(my_nums)

print(next(my_cubic_num_gen))
print(next(my_cubic_num_gen))
print(next(my_cubic_num_gen))
print(next(my_cubic_num_gen))
print(next(my_cubic_num_gen))
print(next(my_cubic_num_gen))

>>> 1
    8
    27
    64
    125
----------------------------------------------------------------
"""StopIteration:"""

매번 next() 함수를 통해서 이터레이터를 진행시킬 수는 없다. 그래서 일반적으로는 for 반복문을 통해 제너레이터 함수를 호출하여 사용한다. 다음의 예시를 보자.

def cubic_num_gen(num_lst):
    for i in num_lst:
        yield i ** 3

my_nums = [1,2,3,4,5]
my_cubic_num_gen = cubic_num_gen(my_nums)

for j in my_cubic_num_gen:
    print(j)
    
>>> 1
    8
    27
    64
    125

for 반복문은 StopIteration 예외가 발생할 때까지 이터레이터를 진행시키기 때문에 시퀀스 내에 존재하는 모든 요소에 대해 해당 작업을 순차적으로 수행한다.

리스트와 제너레이터로 큰 시퀀스 처리하기

글의 서두에서 언급한 것처럼 제너레이터의 장점은 큰 시퀀스를 처리하는 데에 있다. 한 번에 하나의 요소만을 처리하기 때문에 시퀀스의 크기가 커지더라도 메모리를 많이 사용하지 않는다. 아래 코드는 요소개 100만 개인 시퀀스를 리스트와 제너레이터로 처리할 때 걸리는 시간과 메모리 사용량을 나타낸 것이다.

from __future__ import division
import os
import psutil술
import random as r
import time

languages = ['JavaScript', 'HTML/CSS', 'SQL', 'Python', 'Java', 'Bash/Shell/Powershell', 'C#', 'PHP', 'TypeScript','C++','C']
frameworks = ['jQuery', 'React.js', 'Angular', 'ASP.NET', 'Express', 'ASP.NET Core', 'Vue.js', 'Spring', 'Angular.js', 'Django', 'Flask']

process = psutil.Process(os.getpid())
mem_before = process.memory_info().rss/1024/1024

def lang_framework_list(num_people):
    result = []
    for i in range(num_people):
        person = {
            'id': i,
            'language': r.choice(languages),
            'framework': r.choice(frameworks) 
        }
        result.append(person)
    return result
        
t1 = time.process_time()
lang_framework = lang_framework_list(1000000)
t2 = time.process_time()
mem_after = process.memory_info().rss/1024/1024
total_time = t2 - t1

print('시작 전 메모리 사용량: {} MB'.format(mem_before))
print('종료 후 메모리 사용량: {} MB'.format(mem_after))
print('총 소요된 시간: {:.6f} 초'.format(total_time))

>>> 시작  메모리 사용량: 47.90625 MB
    종료  메모리 사용량: 335.4765625 MB
     소요된 시간: 1.323855 

100만개의 요소로 이루어진 시퀀스를 한 번에 처리하려다 보니 메모리 사용량이 크게 늘어난 것을 볼 수 있으며 작업을 수행하는 데 걸린 시간도 1초가 넘는 것을 볼 수 있다.

from __future__ import division
import os
import psutil
import random as r
import time

languages = ['JavaScript', 'HTML/CSS', 'SQL', 'Python', 'Java', 'Bash/Shell/Powershell', 'C#', 'PHP', 'TypeScript','C++','C']
frameworks = ['jQuery', 'React.js', 'Angular', 'ASP.NET', 'Express', 'ASP.NET Core', 'Vue.js', 'Spring', 'Angular.js', 'Django', 'Flask']

process = psutil.Process(os.getpid())
mem_before = process.memory_info().rss/1024/1024

def lang_framework_generator(num_people):
    for i in range(num_people):
        person = {
            'id': i,
            'language': r.choice(languages),
            'framework': r.choice(frameworks) 
        }
        yield person
        
t1 = time.process_time()
lang_framework = lang_framework_generator(1000000)
t2 = time.process_time()
mem_after = process.memory_info().rss/1024/1024
total_time = t2 - t1

print('시작 전 메모리 사용량: {} MB'.format(mem_before))
print('종료 후 메모리 사용량: {} MB'.format(mem_after))
print('총 소요된 시간: {:.6f} 초'.format(total_time))

>>> 시작  메모리 사용량: 48.20703125 MB
    종료  메모리 사용량: 48.20703125 MB
     소요된 시간: 0.000065 

제너레이터로 작업을 진행할 경우 시퀀스가 커져도 전후 메모리 사용량에 변화가 없으며 소요된 시간도 리스트를 한꺼번에 처리하는 것보다 훨씬 더 짧아지는 것을 볼 수 있다.

제너레이터 표현식(Generator Expression)

파이썬에는 리스트로부터 리스트를 빠르게 생성하는 리스트 컴프리헨션(List Comprehension)이 있다. 컴프리헨션은 세트(Set)와 딕셔너리(Dictionary)에도 적용할 수 있다. 이런 컴프리헨션을 잘 사용하면 알고리즘을 작성할 때 파생되는 자료구조를 간명하게 생성할 수 있다. 하지만 위에서 리스트로 작업을 수행했던 것과 같이 큰 시퀀스에 대해 리스트 컴프리헨션을 사용하면 메모리 사용량이 커지고 시간이 오래 걸리게 된다.

파이썬에서는 이런 문제를 해결하기 위해 제너레이터에도 컴프리헨션과 비슷한 표현을 적용할 수 있도록 해놓았다. 이를 제너레이터 표현식(Generator Expression) 이라고 하며 대괄호 [ ] 대신 소괄호 ( ) 를 사용하여 나타낸다. 아래는 1000만 개의 요소를 세제곱하는 작업을 각각 리스트 컴프리헨션과 제너레이터 표현식을 사용하여 수행한 결과이다. 위에서 한 것과 유사한 결과가 도출되는 것을 볼 수 있다.

from __future__ import division
import os
import psutil
import time

process = psutil.Process(os.getpid())
mem_before = process.memory_info().rss/1024/1024

def cubic_num_comp(num):
    return [i**3 for i in range(num)]

t1 = time.process_time()
my_cubic_num = cubic_num_comp(10000000)
t2 = time.process_time()
mem_after = process.memory_info().rss/1024/1024
total_time = t2 - t1

print('시작 전 메모리 사용량: {} MB'.format(mem_before))
print('종료 후 메모리 사용량: {} MB'.format(mem_after))
print('총 소요된 시간: {:.6f} 초'.format(total_time))

>>> 시작  메모리 사용량: 48.046875 MB
    종료  메모리 사용량: 589.55078125 MB
     소요된 시간: 2.111658 

리스트 컴프리헨션을 사용한 경우에는 메모리 사용량이 높은 것을 볼 수 있다.

from __future__ import division
import os
import psutil
import time

process = psutil.Process(os.getpid())
mem_before = process.memory_info().rss/1024/1024

def cubic_num_comp(num):
    yield (i**3 for i in range(num))

t1 = time.process_time()
my_cubic_num = cubic_num_comp(10000000)
t2 = time.process_time()
mem_after = process.memory_info().rss/1024/1024
total_time = t2 - t1

print('시작 전 메모리 사용량: {} MB'.format(mem_before))
print('종료 후 메모리 사용량: {} MB'.format(mem_after))
print('총 소요된 시간: {:.6f} 초'.format(total_time))

>>> 시작  메모리 사용량: 48.0859375 MB
    종료  메모리 사용량: 48.0859375 MB
     소요된 시간: 0.000049 

제너레이터 표현식을 사용하면 시간도 매우 짧으며 메모리 사용량의 변화도 없는 것을 볼 수 있다.

Comment  Read more

튜플(tuple) 혹은 객체(object) 요소를 갖는 리스트 정렬

|

아래 내용은 파이썬 공식 문서TWpower님의 깃허브 를 참조하여 작성하였습니다.

정렬

파이썬에서 리스트를 정렬하는 방법은 두 가지가 있다. 첫 번째는 리스트 자체를 (제자리에서, in-place) 수정하는 .sort() 메서드이다. 두 번째는 새로운 정렬된 리스트를 만드는 sorted() 내장 함수이다. 또한 .sort() 메서드는 리스트에서만 사용할 수 있는데 비해 sorted() 함수는 튜플, 딕셔너리 등 모든 이터러블을 받아들일 수 있다는 차이점이 있다.

본 게시물에서는 이 방법들로 튜플(tuple)이나 객체(object)를 요소로 갖는 리스트를 정렬하는 방법에 알아보고자 한다.

튜플을 요소로 갖는 리스트

다음과 같은 리스트가 있다고 해보자.

tuples_in_list = [('a',3,2), ('b',7,1), ('b',2,3), ('c',2,9), ('a',12,19), ('c',1,1)]

이 함수를 특별한 조건없이 정렬하여 출력하면 결과값은 어떻게 될까?

tuples_in_list = [('a',3,2), ('b',7,1), ('b',2,3), ('c',2,9), ('a',12,19), ('c',1,1)]
tuples_in_list.sort()
print(tuples_in_list)

>>> [('a',3,2), ('a',12,19), ('b',2,3), ('b',7,1), ('c',1,1), ('c',2,9)]

위와 같은 결과가 나오는 이유는 파이썬에서는 튜플을 비교할 때 인덱스를 순차적으로 나아가며 각 인덱스의 값을 비교하기 때문이다. 먼저 인덱스 0 을 기준으로 모든 튜플을 비교하여 정렬한다. 이 과정이 끝나면 그 안에서 인덱스 1 을 기준으로 비교하여 정렬한다. 앞선 예시에서는 첫 번째로 각 튜플의 0 번째 리스트인 'a', 'b', 'c' 를 비교하여 각 튜플을 배치한다. 두 번째로 ('a',3,2), ('a',12,19) 의 1 번째 리스트인 312 를 비교하여 3 이 더 작으므로 순서를 바꾸지 않고 그대로 두게 된다. 이런 방식을 ('b',7,1), ('b',2,3)('c',2,9), ('c',1,1) 에 대해서도 진행하면 위와 같은 결과가 나오게 된다.

그렇다면 특정 인덱스에 우선순위를 두고 비교하고 싶을 때는 어떻게 해야 할까? .sort()혹은 sorted() 내에 있는 key= 라는 파라미터(parameter)에 적절한 함수를 지정해주면 된다. 다음은 위 예시와 같은 리스트를 각 튜플의 마지막 인덱스(인덱스 2)에 우선순위를 두고 정렬하는 방법이다.

tuples_in_list = [('a',3,2), ('b',7,1), ('b',2,3), ('c',2,9), ('a',12,19), ('c',1,1)]
sortedlist_by_idx2 = sorted(tuples_in_list, key=lambda tpls:tpls[2])
print(sortedlist_by_idx2)

>>> [('b',7,1), ('c',1,1), ('a',3,2), ('b',2,3), ('c',2,9), ('a',12,19)]

위에서 출력된 결과를 보면 일단 튜플의 마지막 인덱스에 속하는 값 1, 1, 2, 3, 9 ,19 을 기준으로 정렬되어 있음을 알 수 있다. 마지막 인덱스의 값이 중복되는 ('b',7,1), ('c',1,1) 의 경우에는 정렬 안정성 덕분에 원래의 순서를 유지하고 있음을 알 수 있다. 정렬 안정성에 대해서는 아래에서 더 자세하게 보도록 한다.

객체를 요소로 갖는 리스트

이 방법은 객체를 요소로 갖는 리스트에 대해서도 유용하게 쓸 수 있다. 다음과 같은 클래스와 리스트가 있다고 해보자.

class Student:
    def __init__(self, name, grade, age):
        self.name = name
        self.grade = grade
        self.age = age
    def __repr__(self):
        return repr((self.name, self.grade, self.age))

student_objects = [
    Student('john', 'A', 15),
    Student('jane', 'B', 17),
    Student('dave', 'B', 10),
]

위의 student_objects 를 특정 기준에 따라 정렬하고 싶을 때는 튜플 요소를 정렬할 때와 비슷한 방법을 사용하면 된다. 아래는 각각 학생을 이름순, 나이순으로 정렬한 것이다. key= 파라미터에 조건에 맞게 잘 정렬되었음을 볼 수 있다.

stu_obj_by_name = sorted(student_objects, key=lambda student:student.name)
stu_obj_by_age = sorted(student_objects, key=lambda student:student.age)
print(stu_obj_by_name)
print(stu_obj_by_age)

>>> [('dave', 'B', 10), ('jane', 'B', 17), ('john', 'A', 15)]
>>> [('dave', 'B', 10), ('john', 'A', 15), ('jane', 'B', 17)]

operator 모듈 함수

위와 같은 방법은 굉장히 많이 쓰이므로 파이썬에서는 이를 더욱 쉽고 빠르게 해주는 내장 모듈을 제공하고 있다. 파이썬의 operator 모듈 내에 있는 itemgetter(), attrgetter() 함수는 key= 파라미터에 함수를 만들어 지정해주지 않아도 쉽게 이와 같은 결과를 얻을 수 있도록 해준다.

먼저 itemgetter() 를 쓰는 예시를 보자. itemgetter() 함수는 파라미터로 인덱스를 받으므로 객체를 요소로 갖는 리스트에서는 쓸 수 없다. 인덱스 2를 기준으로 정렬한 결과 위에서 lambda tpls:tpls[2] 와 같은 람다(lambda) 함수를 만들어 지정해준 것과 같은 결과를 얻은 것을 볼 수 있다.

from operator import itemgetter

tuples_in_list = [('a',3,2), ('b',7,1), ('b',2,3), ('c',2,9), ('a',12,19), ('c',1,1)]
sortedlist_by_idx2 = sorted(tuples_in_list, key=itemgetter(2))
print(sortedlist_by_idx2)

>>> [('b',7,1), ('c',1,1), ('a',3,2), ('b',2,3), ('c',2,9), ('a',12,19)]

다음으로 attrgetter() 를 쓰는 예시를 보자. attrgetter() 함수는 파라미터로 속성(attribute)을 받으므로 객체를 요소로 갖는 리스트에서만 쓸 수 있다. 나이를 기준으로 정렬한 결과 위에서 lambda student:student.age 와 같은 람다(lambda) 함수를 만들어 지정해준 것과 같은 결과를 얻은 것을 볼 수 있다.

from operator import attrgetter

student_objects = [
    Student('john', 'A', 15),
    Student('jane', 'B', 17),
    Student('dave', 'B', 10),
]
stu_obj_by_age = sorted(student_objects, key=attrgetter('age'))
print(stu_obj_by_age)

>>> [('dave', 'B', 10), ('john', 'A', 15), ('jane', 'B', 17)]

itemgetter(), attrgetter() 를 함수를 사용하면 2개 이상의 기준으로 정렬하는 것도 쉽게 할 수 있다. 아래는 tuples_in_list 를 인덱스 0을 기준으로 먼저 정렬하고 인덱스 2를 기준으로 정렬하는 코드이다. 출력된 결과에서 ('c',1,1), ('c',2,9) 를 보면 두 가지 우선순위를 기준으로 정렬이 잘 되었음을 알 수 있다. ( itemgetter(0) 로만 정렬했다면 정렬 안정성 때문에 원래의 순서를 유지하여 ('c',2,9), ('c',1,1) 와 같이 출력된다.)

from operator import itemgetter

tuples_in_list = [('a',3,2), ('b',7,1), ('b',2,3), ('c',2,9), ('a',12,19), ('c',1,1)]
sortedlist_by_idx02 = sorted(tuples_in_list, key=itemgetter(0,2))
print(sortedlist_by_idx02)

>>> [('a',3,2), ('a',12,19), ('b',7,1), ('b',2,3), ('c',1,1), ('c',2,9)]

아래는 student_objects 에 몇 개의 객체를 더 추가한 student_object2 를 이름순으로 먼저 정렬한 뒤 나이순으로 정렬하는 코드이다. 출력된 결과에서 ('john', 'C', 14), ('john', 'A', 15) 를 보면 두 가지 우선순위를 기준으로 정렬이 잘 되었음을 알 수 있다.

from operator import attrgetter

student_objects2 = [
    Student('john', 'A', 15),
    Student('jane', 'B', 17),
    Student('dave', 'B', 10),
    Student('john', 'C', 14),
    Student('kevin', 'A', 16)
]
stu_obj2_by_name_age = sorted(student_objects, key=attrgetter('name','age'))
print(stu_obj2_by_name_age)

>>> [('dave', 'B', 10), ('jane', 'B', 17), ('john', 'C', 14), ('john', 'A', 15), ('kevin', 'A', 16)]

정렬 안정성

정렬 안정성(Stability of Sort)이란 같은 키를 가질 때, 원래의 순서가 유지되는 것을 말한다. 예를 들어 tuples_in_list 을 첫 번째 인덱스를 기준으로 정렬한다고 해보자.

from operator import itemgetter

tuples_in_list = [('a',3,2), ('b',7,1), ('b',2,3), ('c',2,9), ('a',12,19), ('c',1,1)]
sortedlist_by_idx0 = sorted(tuples_in_list, key=itemgetter(0))
print(sortedlist_by_idx0)

>>> [('a', 3, 2), ('a', 12, 19), ('b', 7, 1), ('b', 2, 3), ('c', 2, 9), ('c', 1, 1)]

파이썬 .sort() 메서드나 sorted() 내장함수는 팀 소트(Timsort) 라는 정렬 알고리즘을 사용한다. 팀 소트는 안정성을 보장하는 정렬 알고리즘이다. 그래서 이를 활용하여 다양한 정렬을 시도할 수 있다. 위에서 등장한 student_object2 를 성적에 대해서는 내림차순으로 정렬한 뒤 나이에 대해서는 오름차순으로 정리하는 예시를 보자.

from operator import attrgetter

student_objects2 = [
    Student('john', 'A', 15),
    Student('jane', 'B', 17),
    Student('dave', 'B', 10),
    Student('john', 'C', 14),
    Student('kevin', 'A', 16)
]

stu_obj2_by_age = sorted(student_objects, key=attrgetter('age'))
stu_obj2_by_age_graderev = sorted(stu_obj2_by_age, key=attrgetter('grade'), reverse=True)
print(stu_obj2_by_age_graderev)

>>> [('john', 'C', 14), ('dave', 'B', 10), ('jane', 'B', 17), ('john', 'A', 15), ('kevin', 'A', 16)]

이를 다중 패스로 정렬하기 위하여 필드와 순서의 튜플 리스트를 받는 래퍼(wrapper) 함수로 나타낼 수 있다. 추상화한 함수 multisort() 를 호출한 뒤 동일한 조건을 적용했을 때 같은 결과가 나오는 것을 볼 수 있다.

def multisort(xs, specs):
    for key, reverse in reversed(specs):
        xs.sort(key=attrgetter(key), reverse=reverse)
    return xs

multisort(list(student_objects2), (('grade', True), ('age', False)))

>>> [('john', 'C', 14), ('dave', 'B', 10), ('jane', 'B', 17), ('john', 'A', 15), ('kevin', 'A', 16)]

Comment  Read more