[한글 문서화] Blog Post(amuller/word-cloud)

2020-05-31

word_cloud의 blog post 는 amueller/word-cloud의 개발자가 개발과정을 기록한 문서입니다. 이 문서를 번역하고 중간중간 이해를 돕기위해 주석을 달았습니다.


1. 개발하게 된 계기

독일 파이썬 컨퍼런스인 Pycon DE에 참가해서 scikit-learn 작업을 많이 하고 돌아오는 길에, 뭔가 다른걸 해보기로 했습니다. 사실 꽤 이전부터 계획했던 일인데, 그것은 wordle 같은 word cloud를 만드는 것입니다.

저도 물론 word cloud 같은 것이 좀 구식인 것은 알고있지만 어쨌든 나는 word cloud를 좋아합니다. word cloud를 만들 때 시각화를 좀더 흥미롭게 하기 위해서 topic-model을 활용할 수 있겠다는 생각이 들었습다.

그래서 저는 쓸만한 word cloud 오픈소스를 찾았는데, 하나도 발견하지 못했습니다. (이건 예전 일이니 지금은 좀 다를 수도 있겠네요.)

돌아오는 기차 안에서 심심했던 차에 코드를 구상해습니다.

2. 초기 개발 과정 및 문제점

Example1

우선 문서를 불러와야 했는데, 저는 미국 헌법 조문을 사용했습니다.

 with open("constitution.txt") as f:
        lines f.readlines()                                                                            
    text = "".join(lines) 

그 다음에는 단어에 비중을 두어서 추출해야 했습니다. 예를 들어 그 문서에서 단어가 얼마나 자주 등장하는지를 기준으로 하고자 했습니다. 저는 scikit-learn의 CountVectorizer(역주 : 단어들의 출현빈도로 문서들을 벡터화하는 클래스. 또한 이 과정에서 모두 소문자로 변환한다) 를 사용했습니다.

저는 가장 많이 등장하는 200개의 단어를의 출현빈도를 얻었고, 가장 많이 등장하는 출현빈도를 통해 정규화했습니다.

cv = CountVectorizer(min_df=0, charset_error="ignore",
                         stop_words="english", max_features=200)
counts = cv.fit_transform([text]).toarray().ravel()
words = np.array(cv.get_feature_names()) 
# normalize                                                                                                                                             
counts = counts / float(counts.max())

이제 본격적인 작업이 시작됐습니다. 가장 기본 아이디어는 캔버스 위의 공간에 무작위로 공간을 추출하는 것입니다. 이때 단어들을 중요도(출현 빈도수)를 기준으로 크기를 조정할 것입니다. 중요한 것은 단어들이 겹치지 않아야 하겠죠.

단어들을 이미지로 나타내기 위해서는 Python image library(PIL)만한게 없어보였는데 굉장히 불편하더군요. docstring(역주 : 사용자들을 위한 주석)이 전혀 없었습니다.

아무튼, 아래와 같은 코드를 활용해 이미지를 만들 수 있긴합니다.

img_grey = Image.new("L", (width, height))
draw = ImageDraw.Draw(img_grey)

그리고나면 이제 아래와 같은 방법으로 이미지 내에 텍스트를 출력할 수 있습니다.

font = ImageFont.truetype(font_path, font_size)
draw.setfont(font)
draw.text((y, x), "Text that will appear in white", fill="white")

여기서 font_path 는 당신의 시스템에 있는 font의 절대적 경로입니다. 지금은 다른 방법을 알게되긴 했습니다(엄청 어려운 방법은 아닙니다).

이제 우리는 임의의 위치에 단어를 출력하고, 이때 다른 단어들과 겹치지 않는지 확인해 봐야합니다. ImageDraw 클래스에 사용하기 좋은 함수가 있습니다. 바로 textsize인데요. textsize함수는 단어의 크기가 얼마나 되는지 알려줍니다. 우리는 이를 활용해서 겹치는 부분이 있는지 확인해 볼 수 있겠죠.

하지만 불행하게도, 이미지 내부에서 임의의 공간을 추출한다는 것은 매우 비효율적임을 알게됐습니다. 한 이미지 내부의 공간이 이미 다른 단어들로 채워진 상황이라면, 빈 공간을 찾기까지 꽤 많이 시도해봐야 하기 때문이죠.

그래서 저의 다음 아이디어는 일단 이미지 내부에서 사용할 수 있는 빈 공간을 모두 찾은 뒤, 그들 중에서 임의의 공간을 추출하는 것이었습니다. 이미지 내부에서 빈 공간을 찾기 위한 가장 쉬운 방법은 현재이미지 영역을 원하는 단어의 크기(ImageDraw.textsize(next_word)만큼의 박스(픽셀의 행렬)로 convolution계산하는 것이었습니다. 이 계산의 결과값이 0이 되는 공간들이 바로 단어를 출력할 수 있는 공간들이기 때문입니다. 이 계산을 위해 scipy.ndimage.uniform_filter를 사용했고, 잘 작동했습니다.

그런데 우리가 원하는 크기의 단어를 출력할 공간이 더이상 없다면 어떻게 해야할까요? 그럴 경우에 글자크기를 좀더 작게 줄인 후, 똑같은 계산을 다시 해봐야합니다.

이렇게 해봤는데, 코드는 그렇게 빠르지 않았습니다. 또한 꽤 낭비하는 것처럼 보였죠. 그래서 저는 다른 방법을 쓰고 싶었습니다.

3. 문제점 해결 및 최종방법

제가 생각한 건 바로 integral image입니다. 적분이미지는 이미지 내에서 임의의 직사각형 영역의 합을 추출할 수 있는 2차원 구조를 계산하는 방식입니다.

적분 이미지(integral image)는 기본적으로 2d 누적 합(cumulative sum)이며, 다음과 같이 계산할 수 있습니다.

integral_image = np.cumsum(np.cumsum(image, axis=0), axis=1)

이 작업을 수행하면 어떤 크기의 직사각형이든 빠르게 찾을 수 있습니다. 사이즈 (w, h) windows의 경우, 다음과 같이 이 사이즈의 가능한 모든 windows의 합을 알 수 있습니다.

area = (integral_image[w:, h:] + integral_image[:w, :h] - integral_image[w:, :h] - integral_image[:w, h:])

이는 모든 위치를 동시에 쿼리하기 위한 적분 이미지 쿼리 (wikipedia 참조)와 제가 좋아하는 numpy 트릭의 조합입니다.

기본적으로 이것은 위의 convolution과 같으며, 가능한 모든 windows 사이즈를 쿼리할 수 있도록 구조(structure)를 먼저 계산합니다.

단어들을 그린 후에는 다시 적분 이미지를 계산해야 합니다.

아쉽게도 적분 이미지를 사용한 멋진 인덱싱은 살짝 느렸습니다.

반면에 Pycon DE의 Stefan Behnel에게 배운 Cython의 typed memory views를 시도해 볼 수 있는 좋은 기회이기도 했습니다 :)

def query_integral_image(unsigned int[:,:] integral_image, int size_x, int size_y):
    cdef int x = integral_image.shape[0]
    cdef int y = integral_image.shape[1]
    cdef int area, i, j
    x_pos, y_pos = []
    for i in xrange(x - size_x):
        for j in xrange(y - size_y):
            area = integral_image[i, j] + integral_image[i + size_x, j + size_y]
            area -= integral_image[i + size_x, j] + integral_image[i, j + size_y]
            if not area:
                x_pos.append(i)
                y_pos.append(j)

좋습니다! 쓰기 쉽고 C-Speed에 직접적입니다.

마지막 두 줄을 제외하고 … 리스트는 빠르지 않습니다.

저는 이것을 좀 더 빠르게 만들지 못했습니다. (제가 아는 한 array module에는 C API가 없습니다.)

어쨌든 저는 가능한 모든 위치에서 샘플을 뽑으려 했으므로 위 코드를 두 번 랜딩했습니다: 일단 가능한 위치가 얼마나 있는지 센 다음, 샘플을 뽑은 후 샘플을 뽑은 위치로 이동합니다.

C++ 리스트를 사용하면 좀 더 쉽겠지만 제가 너무 게을러 시도하지 못했습니다…

어떻든 간에 지금 저는 꽤 괜찮은 적분 이미지를 가지고 있습니다 :)

그래도 빌드하는 데에는 여전히 시간이 걸립니다… 그래서 새로운 단어를 그린 후에 바뀐 부분만 느릿하게 다시 계산했습니다.

github에서 전체 코드를 확인하세요.

아주 보기 좋지는 않아도 읽을 수 있을 것이라고 생각합니다.

말은 적게 그림은 많이:

Example1

글꼴의 크기를 조정하기 위해 빈도에 따른 임의의 로그연산을 사용했는데 괜찮아 보였습니다.

더 이상의 공간이 없다면 글꼴이 더 작아질 수 있습니다.

오 그리고 물론 단어를 뒤집는 것을 허용했습니다 :) 또한 임의의 색을 사용하여 다뤘습니다. PIL에서 colormaps와 같은 것을 보지 못해서 HSL 공간을 사용하고 색조의 샘플을 뽑았습니다. 좀 더 정교한 계획이 분명히 가능합니다.

다시 조금 더 빠른 속도를 내기 위해 살짝 트릭을 사용했습니다. 먼저 회색 스케일로 모든 것을 계산하고 모든 위치를 저장한 다음에 색상으로 다시했습니다.

한번 더, 이번에는 블로그 테마로 조금 더 (이게 뭔지 추측할 수 있나요?)

Example2

그리고 낮은 채도로

Example3

분명히 외관에 대한 개선의 여지가 있지만, 적당히 다루고 싶다면 이미 좋은 시작이라고 생각합니다.

마지막 코멘트: 저는 낮은 해상도로 모든 작업을 진행한 후에 높은 해상도로 다시 만들어 성능을 개선시키는 것을 생각하고 있습니다. (이 작은 프로젝트에서 유일하게 마음에 걸리는 것임은 분명합니다.)

이것은 두가지 문제가 있습니다: 너무 작은 해상도를 사용한다면 텍스트가 너무 작아서 실제로 보이지 않을 수도 있습니다. 다른 문제는 PIL의 글꼴 크기가 linear하게 확장하지 않는다는 것입니다. 따라서 “이 글꼴을 4배 더 크게 해주세요.” 라고 하는 것은 불가능합니다.

문제를 해결할 수는 있지만 보기 좋지는 않습니다.

그래서 저는 제가 멋지다고 생각하는 Cython / 적분 이미지 방식을 사용했습니다.

코드를 보려고 내려오셨다면 여기있습니다.

추신: 네, 이것은 css / html4를 생성하지 않습니다. 하지만 텍스트 크기와 위치를 알면 이것을 백엔드로 사용하여 html 페이지를 만드는 것은 쉬울 것입니다. PR 환영해요 ;)