├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── SUMMARY.md ├── data ├── 134963.txt ├── 134963_norm.txt ├── 91031.txt ├── 91031_norm.txt ├── 99714.txt └── 99714_norm.txt ├── docs └── examples │ ├── README.md │ └── examples.md ├── evaluation ├── README.md ├── evaluation.py └── lalaland_comments │ ├── README.md │ ├── krwordrank_keysent.txt │ ├── krwordrank_keyword.txt │ ├── performance.csv │ ├── textrank_cos_keysent.txt │ ├── textrank_keysent.txt │ └── textrank_keyword.txt ├── krwordrank ├── __init__.py ├── about.py ├── graph │ ├── __init__.py │ └── _rank.py ├── hangle │ ├── __init__.py │ └── _hangle.py ├── sentence │ ├── __init__.py │ ├── _sentence.py │ └── _tokenizer.py └── word │ ├── __init__.py │ └── _word.py ├── reference └── 2014_JKIIE_KimETAL_KR-WordRank.pdf ├── requirements.txt ├── setup.py ├── tests └── test_krwordrank.py └── tutorials ├── figs ├── kr_wordrank_fig1.png └── kr_wordrank_fig2.png ├── krwordrank_keysentence.ipynb ├── krwordrank_word_and_keyword_extraction.ipynb ├── lalaland_wordcloud.png └── word_cloud_with_kr-wordrank.ipynb /.gitattributes: -------------------------------------------------------------------------------- 1 | tutorials/* linguist-vendored 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # MacOS 2 | .DS_Store 3 | 4 | # Python 5 | .pyc 6 | __pycache__/ 7 | dist/ 8 | build/ 9 | *.egg-info/ 10 | 11 | # Experiments 12 | tmp/ 13 | 14 | # Jupyter notebook 15 | .ipynb_checkpoints/ 16 | 17 | # gitbook 18 | _book/ 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | KR-WordRank: Python package for unsupervised Korean word and keyword extractor 2 | 3 | Copyright (C) 2019 lovit 4 | 5 | 6 | GNU LESSER GENERAL PUBLIC LICENSE 7 | Version 3, 29 June 2007 8 | 9 | Copyright (C) 2007 Free Software Foundation, Inc. 10 | Everyone is permitted to copy and distribute verbatim copies 11 | of this license document, but changing it is not allowed. 12 | 13 | 14 | This version of the GNU Lesser General Public License incorporates 15 | the terms and conditions of version 3 of the GNU General Public 16 | License, supplemented by the additional permissions listed below. 17 | 18 | 0. Additional Definitions. 19 | 20 | As used herein, "this License" refers to version 3 of the GNU Lesser 21 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 22 | General Public License. 23 | 24 | "The Library" refers to a covered work governed by this License, 25 | other than an Application or a Combined Work as defined below. 26 | 27 | An "Application" is any work that makes use of an interface provided 28 | by the Library, but which is not otherwise based on the Library. 29 | Defining a subclass of a class defined by the Library is deemed a mode 30 | of using an interface provided by the Library. 31 | 32 | A "Combined Work" is a work produced by combining or linking an 33 | Application with the Library. The particular version of the Library 34 | with which the Combined Work was made is also called the "Linked 35 | Version". 36 | 37 | The "Minimal Corresponding Source" for a Combined Work means the 38 | Corresponding Source for the Combined Work, excluding any source code 39 | for portions of the Combined Work that, considered in isolation, are 40 | based on the Application, and not on the Linked Version. 41 | 42 | The "Corresponding Application Code" for a Combined Work means the 43 | object code and/or source code for the Application, including any data 44 | and utility programs needed for reproducing the Combined Work from the 45 | Application, but excluding the System Libraries of the Combined Work. 46 | 47 | 1. Exception to Section 3 of the GNU GPL. 48 | 49 | You may convey a covered work under sections 3 and 4 of this License 50 | without being bound by section 3 of the GNU GPL. 51 | 52 | 2. Conveying Modified Versions. 53 | 54 | If you modify a copy of the Library, and, in your modifications, a 55 | facility refers to a function or data to be supplied by an Application 56 | that uses the facility (other than as an argument passed when the 57 | facility is invoked), then you may convey a copy of the modified 58 | version: 59 | 60 | a) under this License, provided that you make a good faith effort to 61 | ensure that, in the event an Application does not supply the 62 | function or data, the facility still operates, and performs 63 | whatever part of its purpose remains meaningful, or 64 | 65 | b) under the GNU GPL, with none of the additional permissions of 66 | this License applicable to that copy. 67 | 68 | 3. Object Code Incorporating Material from Library Header Files. 69 | 70 | The object code form of an Application may incorporate material from 71 | a header file that is part of the Library. You may convey such object 72 | code under terms of your choice, provided that, if the incorporated 73 | material is not limited to numerical parameters, data structure 74 | layouts and accessors, or small macros, inline functions and templates 75 | (ten or fewer lines in length), you do both of the following: 76 | 77 | a) Give prominent notice with each copy of the object code that the 78 | Library is used in it and that the Library and its use are 79 | covered by this License. 80 | 81 | b) Accompany the object code with a copy of the GNU GPL and this license 82 | document. 83 | 84 | 4. Combined Works. 85 | 86 | You may convey a Combined Work under terms of your choice that, 87 | taken together, effectively do not restrict modification of the 88 | portions of the Library contained in the Combined Work and reverse 89 | engineering for debugging such modifications, if you also do each of 90 | the following: 91 | 92 | a) Give prominent notice with each copy of the Combined Work that 93 | the Library is used in it and that the Library and its use are 94 | covered by this License. 95 | 96 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 97 | document. 98 | 99 | c) For a Combined Work that displays copyright notices during 100 | execution, include the copyright notice for the Library among 101 | these notices, as well as a reference directing the user to the 102 | copies of the GNU GPL and this license document. 103 | 104 | d) Do one of the following: 105 | 106 | 0) Convey the Minimal Corresponding Source under the terms of this 107 | License, and the Corresponding Application Code in a form 108 | suitable for, and under terms that permit, the user to 109 | recombine or relink the Application with a modified version of 110 | the Linked Version to produce a modified Combined Work, in the 111 | manner specified by section 6 of the GNU GPL for conveying 112 | Corresponding Source. 113 | 114 | 1) Use a suitable shared library mechanism for linking with the 115 | Library. A suitable mechanism is one that (a) uses at run time 116 | a copy of the Library already present on the user's computer 117 | system, and (b) will operate properly with a modified version 118 | of the Library that is interface-compatible with the Linked 119 | Version. 120 | 121 | e) Provide Installation Information, but only if you would otherwise 122 | be required to provide such information under section 6 of the 123 | GNU GPL, and only to the extent that such information is 124 | necessary to install and execute a modified version of the 125 | Combined Work produced by recombining or relinking the 126 | Application with a modified version of the Linked Version. (If 127 | you use option 4d0, the Installation Information must accompany 128 | the Minimal Corresponding Source and Corresponding Application 129 | Code. If you use option 4d1, you must provide the Installation 130 | Information in the manner specified by section 6 of the GNU GPL 131 | for conveying Corresponding Source.) 132 | 133 | 5. Combined Libraries. 134 | 135 | You may place library facilities that are a work based on the 136 | Library side by side in a single library together with other library 137 | facilities that are not Applications and are not covered by this 138 | License, and convey such a combined library under terms of your 139 | choice, if you do both of the following: 140 | 141 | a) Accompany the combined library with a copy of the same work based 142 | on the Library, uncombined with any other library facilities, 143 | conveyed under the terms of this License. 144 | 145 | b) Give prominent notice with the combined library that part of it 146 | is a work based on the Library, and explaining where to find the 147 | accompanying uncombined form of the same work. 148 | 149 | 6. Revised Versions of the GNU Lesser General Public License. 150 | 151 | The Free Software Foundation may publish revised and/or new versions 152 | of the GNU Lesser General Public License from time to time. Such new 153 | versions will be similar in spirit to the present version, but may 154 | differ in detail to address new problems or concerns. 155 | 156 | Each version is given a distinguishing version number. If the 157 | Library as you received it specifies that a certain numbered version 158 | of the GNU Lesser General Public License "or any later version" 159 | applies to it, you have the option of following the terms and 160 | conditions either of that published version or of any later version 161 | published by the Free Software Foundation. If the Library as you 162 | received it does not specify a version number of the GNU Lesser 163 | General Public License, you may choose any version of the GNU Lesser 164 | General Public License ever published by the Free Software Foundation. 165 | 166 | If the Library as you received it specifies that a proxy can decide 167 | whether future versions of the GNU Lesser General Public License shall 168 | apply, that proxy's public statement of acceptance of any version is 169 | permanent authorization for you to choose that version for the 170 | Library. 171 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KR-WordRank: Unsupervised Korean Word & Keyword Extractor 2 | 3 | - pure Python code 4 | - author: Lovit (Hyunjoong Kim) 5 | - reference: [Kim, H. J., Cho, S., & Kang, P. (2014). KR-WordRank: An Unsupervised Korean Word Extraction Method Based on WordRank. Journal of Korean Institute of Industrial Engineers, 40(1), 18-33][paper] 6 | 7 | ## Keyword extraction 8 | 9 | Substring graph를 만들기 위하여 substring의 최소 등장 빈도수 (min count)와 substring의 최대 길이 (max length)를 입력해야 합니다. 10 | 11 | ```python 12 | from krwordrank.word import KRWordRank 13 | 14 | min_count = 5 # 단어의 최소 출현 빈도수 (그래프 생성 시) 15 | max_length = 10 # 단어의 최대 길이 16 | wordrank_extractor = KRWordRank(min_count=min_count, max_length=max_length) 17 | ``` 18 | 19 | KR-WordRank는 PageRank 와 비슷한 graph ranking 알고리즘을 이용하여 단어를 추출합니다 (HITS algorithm 을 이용합니다). Substring graph에서 node (substrig) 랭킹을 계산하기 위하여 graph ranking 알고리즘의 parameters 가 입력되야 합니다. 20 | 21 | ```python 22 | beta = 0.85 # PageRank의 decaying factor beta 23 | max_iter = 10 24 | texts = ['예시 문장 입니다', '여러 문장의 list of str 입니다', ... ] 25 | keywords, rank, graph = wordrank_extractor.extract(texts, beta, max_iter) 26 | ``` 27 | 28 | Graph ranking 이 높은 노드들(substrings)이 후처리 과정을 거쳐 단어로 출력됩니다. 영화 '라라랜드'의 영화 평 데이터에서 키워드 (단어) 추출을 한 결과 예시가 tutorials에 있습니다. 29 | 30 | ```python 31 | for word, r in sorted(keywords.items(), key=lambda x:x[1], reverse=True)[:30]: 32 | print('%8s:\t%.4f' % (word, r)) 33 | ``` 34 | 35 | 영화: 229.7889 36 | 관람객: 112.3404 37 | 너무: 78.4055 38 | 음악: 37.6247 39 | 정말: 37.2504 40 | .... 41 | 42 | Python 의 wordcloud package 를 이용하면 키워드에 관한 word cloud figure 를 그릴 수 있습니다. 43 | 44 | Figure 에 나타내지 않을 일반적인 단어 (stopwords) 를 제거하여 passwords 를 만듭니다. dict 형식으로 {단어:점수} 형식이어야 합니다. 45 | 46 | ```python 47 | stopwords = {'영화', '관람객', '너무', '정말', '보고'} 48 | passwords = {word:score for word, score in sorted( 49 | keywords.items(), key=lambda x:-x[1])[:300] if not (word in stopwords)} 50 | ``` 51 | 52 | 혹은 위의 과정을 간단히 summarize_with_keywords 함수로 진행할 수도 있습니다. 53 | 54 | ```python 55 | from krwordrank.word import summarize_with_keywords 56 | 57 | keywords = summarize_with_keywords(texts, min_count=5, max_length=10, 58 | beta=0.85, max_iter=10, stopwords=stopwords, verbose=True) 59 | keywords = summarize_with_keywords(texts) # with default arguments 60 | ``` 61 | 62 | wordcloud 의 설치는 아래의 명령어로 설치할 수 있습니다. 63 | 64 | pip install wordcloud 65 | 66 | wordcloud 가 이용하는 기본 폰트는 한글 지원이 되지 않습니다. 한글을 지원하는 본인의 폰트를 찾아 font_path 를 준비합니다. 그림의 크기 (width, height) 와 배경색 (background_color) 등을 지정한 뒤, generate_from_frequencies() 함수를 이용하여 그림을 그립니다. 67 | 68 | ```python 69 | from wordcloud import WordCloud 70 | 71 | # Set your font path 72 | font_path = 'YOUR_FONT_DIR/truetype/nanum/NanumBarunGothic.ttf' 73 | 74 | krwordrank_cloud = WordCloud( 75 | font_path = font_path, 76 | width = 800, 77 | height = 800, 78 | background_color="white" 79 | ) 80 | 81 | krwordrank_cloud = krwordrank_cloud.generate_from_frequencies(passwords) 82 | ``` 83 | 84 | Jupyter notebook 에서 그림을 그릴 때에는 반드시 아래처럼 %matplotlib inline 을 입력해야 합니다. .py 파일로 만들 때에는 이를 입력하지 않습니다. 85 | 86 | ```python 87 | %matplotlib inline 88 | import matplotlib.pyplot as plt 89 | 90 | fig = plt.figure(figsize=(10, 10)) 91 | plt.imshow(krwordrank_cloud, interpolation="bilinear") 92 | plt.show() 93 | ``` 94 | 95 | 그려진 그림을 저장할 수 있습니다. 96 | 97 | ```python 98 | fig.savefig('./lalaland_wordcloud.png') 99 | ``` 100 | 101 | 저장된 그림은 아래와 같습니다. 102 | 103 | ![](./tutorials/lalaland_wordcloud.png) 104 | 105 | ## Key-sentence extraction 106 | 107 | KR-WordRank >= `1.0.0` 부터는 key sentence extraction 을 제공합니다. KR-WordRank 는 한국어의 토크나이저 기능이 내제되어 있기 때문에 토크나이징이 된 문장 간 유사도를 이용하는 TextRank 방식을 이용하기 어렵습니다. 대신 KR-WordRank 에서는 keywords 를 많이 포함한 문장을 핵심 문장으로 선택합니다. 문장을 추출하는 원리는 추출된 키워드의 랭크값을 이용하여 키워드 벡터를 만든 뒤, 코싸인 유사도 기준으로 입력된 문장 벡터가 키워드 벡터와 유사한 문장을 선택하는 것입니다. 108 | 109 | summarize_with_sentences 함수에 texts 를 입력하면 KR-WordRank 를 학습하여 키워드와 이를 이용한 핵심 문장을 선택합니다. 110 | 111 | ```python 112 | from krwordrank.sentence import summarize_with_sentences 113 | 114 | texts = [] # 라라랜드 영화평 115 | keywords, sents = summarize_with_sentences(texts, num_keywords=100, num_keysents=10) 116 | ``` 117 | 118 | keywords 에는 KR-WordRank 로부터 학습된 `num_keywords` 개수의 키워드와 이들의 랭크 값이 dict{str:float} 형식으로 저장되어 있습니다. 119 | 120 | ``` 121 | {'영화': 201.02402099523516, 122 | '너무': 81.53699026386887, 123 | '정말': 40.53709233921311, 124 | '음악': 40.43446188536923, 125 | '마지막': 38.598509495213484, 126 | '뮤지컬': 23.198810378709844, 127 | '최고': 21.810147306627464, 128 | '사랑': 20.638511587426862, 129 | '꿈을': 20.43744237599688, 130 | '아름': 20.324710458174806, 131 | '영상': 20.283994278960186, 132 | '여운이': 19.471356929084546, 133 | '진짜': 19.06433920013137, 134 | '노래': 18.732801785265316, 135 | ... 136 | } 137 | ``` 138 | sents 에는 `num_sents` 개의 핵심 문장이 list of str 형식으로 포함되어 있습니다. 139 | 140 | ``` 141 | ['여운이 크게남는영화 엠마스톤 너무 사랑스럽고 라이언고슬링 남자가봐도 정말 매력적인 배우인듯 영상미 음악 연기 구성 전부 좋았고 마지막 엔딩까지 신선하면서 애틋하구요 30중반에 감정이 많이 메말라있었는데 오랜만에 가슴이 촉촉해지네요', 142 | '영상미도 너무 아름답고 신나는 음악도 좋았다 마지막 세바스찬과 미아의 눈빛교환은 정말 마음 아팠음 영화관에 고딩들이 엄청 많던데 고딩들은 영화 내용 이해를 못하더라ㅡㅡ사랑을 깊게 해본 사람이라면 누구나 느껴볼수있는 먹먹함이 있다', 143 | '정말 영상미랑 음악은 최고였다 그리고 신선했다 음악이 너무 멋있어서 연기를 봐야 할지 노래를 들어야 할지 모를 정도로 그리고 보고 나서 생각 좀 많아진 영화 정말 이 연말에 보기 좋은 영화 인 것 같다', 144 | '무언의 마지막 피아노연주 완전 슬픔ㅠ보는이들에게 꿈을 상기시켜줄듯 또 보고 싶은 내생에 최고의 뮤지컬영화였음 단순할수 있는 내용에 뮤지컬을 가미시켜째즈음악과 춤으로 지루할틈없이 빠져서봄 ost너무좋았음', 145 | '처음엔 초딩들 보는 그냥 그런영화인줄 알았는데 정말로 눈과 귀가 즐거운 영화였습니다 어찌보면 뻔한 스토리일지 몰라도 그냥 보고 듣는게 즐거운 그러다가 정말 마지막엔 너무 아름답고 슬픈 음악이 되어버린', 146 | '정말 멋진 노래와 음악과 영상미까지 정말 너무 멋있는 영화 눈물을 흘리면서 봤습니다 영화가 끝난 순간 감탄과 동시에 여운이 길게 남아 또 눈물을 흘렸던내 인생 최고의 뮤지컬 영화', 147 | '평소 뮤지컬 영화 좋아하는 편인데도 평점에 비해 너무나 별로였던 영화 재즈음악이나 영상미 같은 건 좋았지만 줄거리도 글쎄 결말은 정말 별로 6 7점 정도 주는게 맞다고 생각하지만 개인적으로 후반부가 너무 별로여서', 148 | '오랜만에 좋은 영화봤다는 생각들었구요 음악도 영상도 스토리도 너무나좋았고 무엇보다 진한 여운이 남는 영화는 정말 오랜만이었어요 연인끼리 가서 보기 정말 좋은영화 너뮤너뮤너뮤 재밌게 잘 봤습니다', 149 | '음악 미술 연기 등 모든 것이 좋았지만 마지막 결말이 너무 현실에 뒤떨어진 꿈만 같다 꿈을 이야기하는 영화지만 과정과 결과에 있어 예술가들의 현실을 너무 반영하지 못한 것이 아닌가하는 생각이든다 그래서 보고 난 뒤 나는 꿈을 꿔야하는데 허탈했다', 150 | '마지막 회상씬의 감동이 잊혀지질않는다마지막 십분만으로 티켓값이 아깝지않은 영화 음악들도 너무 아름다웠다옛날 뮤지컬 같은 빈티지영상미도 최고'] 151 | ``` 152 | 153 | 몇 가지 패러매터를 추가할 수 있습니다. 길이가 지나치게 길거나 짧은 문장을 제거하기 위해 penalty 함수를 정의합니다. 아래는 길이가 25 글자부터 80 글자인 문장을 선호한다는 의미입니다. stopwords 는 키워드에서 제거합니다. 이들은 키워드벡터를 만들 때에도 이용되지 않습니다. 또한 키워드 벡터와 유사한 문장을 우선적으로 선택하다보면 이전에 선택된 문장과 중복되는 문장들이 선택되기도 합니다. 이는 `diversity` 를 이용하여 조절할 수 있습니다. `diversity` 는 코싸인 유사도 기준 핵심문장 간의 최소 거리 입니다. 이 값이 클수록 다양한 문장이 선택됩니다. 154 | 155 | ```python 156 | penalty = lambda x:0 if (25 <= len(x) <= 80) else 1 157 | stopwords = {'영화', '관람객', '너무', '정말', '진짜'} 158 | 159 | keywords, sents = summarize_with_sentences( 160 | texts, 161 | penalty=penalty, 162 | stopwords = stopwords, 163 | diversity=0.5, 164 | num_keywords=100, 165 | num_keysents=10, 166 | verbose=False 167 | ) 168 | ``` 169 | 170 | 이번에 추출된 키워드에는 `영화`, `관람객`, `너무` 와 같은 stopwords 가 제거되었습니다. 171 | 172 | ``` 173 | {'음악': 40.43446188536923, 174 | '마지막': 38.598509495213484, 175 | '뮤지컬': 23.198810378709844, 176 | '최고': 21.810147306627464, 177 | '사랑': 20.638511587426862, 178 | '꿈을': 20.43744237599688, 179 | '아름': 20.324710458174806, 180 | '영상': 20.283994278960186, 181 | '여운이': 19.471356929084546, 182 | '노래': 18.732801785265316, 183 | ... 184 | } 185 | ``` 186 | 187 | 핵심 문장도 길이가 25 ~ 80 글자인 문장들을 선호합니다. 188 | 189 | ``` 190 | ['최고라는 말밖엔 음악 연출 영상 스토리 모두완벽 마지막 10분잊을수없다 한편의 뮤지컬을본듯한 느낌인생영화', 191 | '기대했었는데 저한텐 스토리도 음악도 평범했어요 영화보는내내 지루하다는 느낌을 많이 받았는데 신기하게도 마지막 씬을 보고나니 여운이 남네요', 192 | '슬펐지만 아름다웠던 두사람의 사랑과 갈등 그리고 음악 마지막 오버랩은 그냥 할말을 잃었습니다 여운이 남는 영화', 193 | '마지막 회상신에서 눈물이 왈칵 쏟아질뻔했다 올해중 최고의 영화를 본거 같다음악이며 배우들이며 영상이며 다시 또 보고싶은 그런 영화이다', 194 | '예쁜 영상과 아름다운 음악 꿈을 쫒는 두사람의 선택이 달랐다면 어땠을까 상상하는 장면이 인상깊었다 쓸쓸하지만 현실적인 사랑이랄까', 195 | '음악도 좋고 미아와 세바스티안의 아름다운 사랑과 예술에 대한 열정이 감동적이었습니다 재즈음악을 사랑하고 뮤지컬을 좋아하는 사람들에게 강추합니다', 196 | '생각보다 굉장히 재미있는 뻔한 결말도 아니고 아름다운 음악과 현실적인 스토리구성 모두에게 와닿을법한 울림들이 차 좋았어요 추천', 197 | '최고입니다 마지막 장면을 위해 음악과 함께 달려왔고현실적이지만 모두의 가슴을 뭉클하게 만드는 멋진 결말입니다 노래가 머리속에서 떠나질않네요', 198 | '먼저 음악이 너무 좋고아름다운 영상미만으로도 최고네요 아름답지만 짠내도 나구요 별 생각없이 봤는데 강추입니다 영화보고 계속 음악이 귀에 맴돌아요', 199 | '초반에 좀 지루하나 음악도 좋고 영상도 좋아서 보는 맛이 있어요 마지막이 좋았어요'] 200 | ``` 201 | 202 | 만약 `마지막`이라는 단어가 포함된 문장도 핵심 문장에서 제거하고 싶다면 아래처럼 `penalty` 함수를 변경할 수 있습니다. 203 | 204 | ```python 205 | penalty=lambda x:0 if (25 <= len(x) <= 80 and not '마지막' in x) else 1, 206 | keywords, sents = summarize_with_sentences( 207 | texts, 208 | penalty=penalty, 209 | stopwords = stopwords, 210 | diversity=0.5, 211 | num_keywords=100, 212 | num_keysents=10, 213 | verbose=False 214 | ) 215 | 216 | print(sents) 217 | ``` 218 | 219 | ``` 220 | ['예쁜 영상과 아름다운 음악 꿈을 쫒는 두사람의 선택이 달랐다면 어땠을까 상상하는 장면이 인상깊었다 쓸쓸하지만 현실적인 사랑이랄까', 221 | '음악도 좋고 미아와 세바스티안의 아름다운 사랑과 예술에 대한 열정이 감동적이었습니다 재즈음악을 사랑하고 뮤지컬을 좋아하는 사람들에게 강추합니다', 222 | '생각보다 굉장히 재미있는 뻔한 결말도 아니고 아름다운 음악과 현실적인 스토리구성 모두에게 와닿을법한 울림들이 차 좋았어요 추천', 223 | '먼저 음악이 너무 좋고아름다운 영상미만으로도 최고네요 아름답지만 짠내도 나구요 별 생각없이 봤는데 강추입니다 영화보고 계속 음악이 귀에 맴돌아요', 224 | '사랑 꿈 현실 모든걸 다시한번 생각하게 하는 영화였어요 영상미도 너무 예쁘고 주인공도 예쁘고 내용도 아름답네요ㅠㅠ 인생 영화', 225 | '너무 좋은 영화 스토리는 비숫한것같아요 그래도 음악 영상 이루어지지않는 사랑을 더 매력적으로 전달한영화인것같아요 보고나서도 여운이 남는', 226 | '노래도 좋고 영상미도 좋고 그리고 배우들 연기까지 정말 좋았어요 개인적으로 뮤지컬 형식 영화를 안좋아하는 편인데 재밌게 봤습니다', 227 | '16년 최고의영화 인생영화입니다 영상미 색감 음악 감정선 다좋았는데 엔딩이 참현실적이네요 ㅎㅎ 참 공감되고 감동받았습니다 씁쓸하니 정말잘봤어요', 228 | '사실 두번째 보는 영화입니다 영상 편집과 음악이 너무 좋아요 어떻게 보면 너무나 현실적일 수 있는 결말이 슬프기하지만 아름답습니다', 229 | '영화사에 남을 최고의 뮤지컬영화입니다 음악과 영상이 너무 아름답고 두 주연배우의 연기는 매우 감동적입니다 무조건 보세요 최고'] 230 | ``` 231 | 232 | 더 자세한 key sentence extraction tutorials 은 tutorials 폴더의 krwordrank_keysentence.ipynb 파일을 참고하세요. 233 | 234 | ## Setup 235 | 236 | ``` 237 | pip install krwordrank 238 | ``` 239 | 240 | tested in 241 | - python 3.5.9 242 | - python 3.7.7 243 | 244 | ## Requirements 245 | 246 | - Python >= 3.5 247 | - numpy 248 | - scipy 249 | 250 | [![Analytics](https://ga-beacon.appspot.com/UA-129549627-3/kr-wordrank/readme)](https://github.com/lovit/kr-wordrank) 251 | 252 | [paper]: https://github.com/lovit/KR-WordRank/raw/master/reference/2014_JKIIE_KimETAL_KR-WordRank.pdf 253 | -------------------------------------------------------------------------------- /SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | * [Introduction](README.md) 4 | * [Example](docs/examples/README.md) 5 | * [example_1](docs/examples/examples.md) 6 | 7 | -------------------------------------------------------------------------------- /docs/examples/README.md: -------------------------------------------------------------------------------- 1 | # Example front page 2 | -------------------------------------------------------------------------------- /docs/examples/examples.md: -------------------------------------------------------------------------------- 1 | Examples will be published, soon. 2 | -------------------------------------------------------------------------------- /evaluation/README.md: -------------------------------------------------------------------------------- 1 | # Evaluation for key-sentences extraction 2 | 3 | ROUGE-1 을 이용한 sentence extraction based summarization performance evaluation 입니다. 4 | 5 | ## Key-sentences extraction in KR-WordRank 6 | 7 | TextRank 와 KR-WordRank 는 keyword extraction 기능과 key-sentence extracion 기능을 모두 제공합니다. 단, TextRank 는 잘 구축된 토크나이저가 필요합니다. 만약 미등록단어 문제가 발생할 경우에는 해당 단어가 키워드로 추출되지 않습니다. KR-WordRank 는 이러한 문제를 해결하기 위하여 제안된 방법으로, 한국어 텍스트에서 데이터 기반으로 단어를 추출함과 동시에 해당 문장 집합을 잘 설명할 수 있는 키워드를 추출합니다. 8 | 9 | KR-WordRank >= 1.0.0 부터는 핵심 문장을 추출하는 기능도 추가되었으며, 이 역시 어떠한 토크나이저도 필요로 하지 않습니다. 10 | 11 | KR-WordRank 가 핵심 문장을 추출하는 원리는 아래와 같습니다. KR-WordRank 가 추출한 키워드들의 ranks 를 이용하여 keyword vector 를 만듭니다. 그리고 문장에서 키워드 점수를 단어 점수로 이용하여 soynlp 의 MaxScoreTokenizer 를 이용하여 문장을 토크나이징 합니다. 즉 데이터 기반으로 단어를 추출하고, 이를 이용하여 토크나이징을 하는 개념입니다. 모든 문장들을 키워드의 포함 유무를 표현하는 Boolean vector 로 만든 뒤, keyword vector 와의 거리를 계산합니다. 거리가 가까운 문장은 키워드들을 많이 포함하고 있는 문장입니다. 이를 핵심 문장으로 선택합니다. 12 | 13 | 단, 이 방법은 비슷한 문장들을 핵심 문장으로 선택할 가능성이 있습니다. 그렇기 때문에 처음 한 문장은 keyword vector 와의 거리가 가장 짧은 문장을 선택합니다. 그 뒤 선택된 문장과 나머지 모든 문장간의 코싸인 거리를 계산하고, 그 거리가 사용자가 지정한 임계값 (threshold, argument name = diversity) 보다 작은 경우에는 모두 2 를 더합니다. 2 는 코싸인 거리가 가질 수 있는 최대값이기 때문입니다. 그 뒤, 다시 한 번 거리의 누적값이 가장 작은 문장을 선택하며, 이를 문장 개수만큼 반복합니다. 14 | 15 | ## Evaluation logic 16 | 17 | 핵심 문장 추출은 주어진 문서 혹은 문장 집합의 요약에 이용됩니다. 문서 요약 (summarization) 분야에서 자주 이용되는 성능 평가 척도로는 ROGUE-N 이 있습니다. ROGUE-N 은 reference summaries 와 system summaries 간의 n-gram recall 을 성능 평가 척도로 이용합니다. 18 | 19 | 예를 들어 아래의 문장이 한 문서의 요약문이라고 가정합니다. 20 | 21 | ``` 22 | the cat was under the bed 23 | ``` 24 | 25 | 그리고 아래의 문장이 시스템에 의하여 추출된 핵심 문장이라고 가정합니다. 26 | 27 | ``` 28 | the cat was found under the bed 29 | ``` 30 | 31 | 추출된 핵심 문장이 좋은 문장이라면, 정답 요약 문장의 단어들을 많이 포함해야 합니다. ROGUE-1 은 unigram 에서의 recall 값입니다. 추출된 문장에는 정답 요약 문장의 모든 단어가 포함되어 있기 때문에 recall = 1 입니다. ROGUE-2 는 bigram 에서의 recall 값입니다. 아래는 정답 문장에서의 bigrams 입니다. 32 | 33 | ``` 34 | the cat 35 | cat was 36 | was under 37 | under the 38 | the bed 39 | ``` 40 | 41 | 아래는 추출된 핵심 문장에서의 bigrams 입니다. 42 | 43 | ``` 44 | the cat 45 | cat was 46 | was found 47 | found under 48 | under the 49 | the bed 50 | ``` 51 | 52 | 'was under' 라는 bigram 이 recall 되지 않았기 때문에 recall = 4/5 입니다. 53 | 54 | 물론 ROGUE measurement 는 그 성능의 신뢰성에 대해 고민할 부분이 많기는 하지만, 그 외에 이용할 수 있는 적절한 성능 평가 지표가 많지 않습니다. 그렇기 때문에 이번 실험에서도 ROGUE 를 이용하였습니다. 55 | 56 | 하지만 한 가지 문제가 더 발생합니다. 적절한 정답 문장을 만들 수가 없습니다. 그래서 생각한 방법은 각각의 알고리즘이 추출한 핵심 단어를 references 로 이용하는 것입니다. 알고리즘이 추출한 핵심 단어 집합을 좋은 summarization keywords 라 가정할 때, 추출된 핵심 문장들은 이 키워드들을 다수 포함해야 합니다. 그리고 KR-WordRank 는 unigram extraction 을 하기 때문에 ROUGE-1 을 이용하였습니다. 57 | 58 | 아래의 디렉토리에는 각각의 데이터셋에 따른 ROGUE-1 성능을 측정합니다. 59 | 60 | -------------------------------------------------------------------------------- /evaluation/evaluation.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | Performance = namedtuple('Performance', 'n_keywords n_keysents rouge1'.split()) 4 | 5 | def rouge1(keywords, keysents, tokenize, n_keywords=None, n_keysents=None): 6 | """ 7 | Arguments 8 | --------- 9 | keywords : dict 10 | {keyword : rank} dictionary 11 | keysents : list of str 12 | Keysentence list 13 | tokenize : callable 14 | tokenize(str) = list of str 15 | n_keywords : list of int or None 16 | If None, n_keywords = [10, 20, 30, 50, 100] 17 | n_keysents: list of int or None 18 | If None, n_keysents = [3, 5, 10, 20, 30] 19 | 20 | Returns 21 | ------- 22 | list of namedtuple 23 | Performance(n_keywords, n_keysents, rouge1) 24 | """ 25 | if n_keywords is None: 26 | n_keywords = [10, 20, 30, 50, 100] 27 | if n_keysents is None: 28 | n_keysents = [3, 5, 10, 20, 30] 29 | 30 | keysents = [tokenize(sent) for sent in keysents] 31 | performance = [] 32 | for n_keyword in n_keywords: 33 | keywords_ = {w for w, _ in sorted(keywords.items(), key=lambda x:-x[1])[:n_keyword]} 34 | for n_keysent in n_keysents: 35 | sents = keysents[:n_keysent] 36 | word_set = {w for sent in sents for w in sent} 37 | word_set = {w for w in word_set if w in keywords_} 38 | recall = len(word_set) / n_keyword 39 | performance.append(Performance(n_keyword, n_keysent, recall)) 40 | return performance 41 | -------------------------------------------------------------------------------- /evaluation/lalaland_comments/README.md: -------------------------------------------------------------------------------- 1 | # ROUGE-1 Performacne 2 | 3 | TextRank and TextRank(cos) uses Komoran as tokenizer 4 | 5 | | n_keywords | n_keysents | KR-WordRank | TextRank | TextRank(cos) | 6 | | --- | --- | --- | --- | --- | 7 | | 10 | 3 | 0.8 | 0.6 | 0.4 | 8 | | 10 | 5 | 1.0 | 0.7 | 0.5 | 9 | | 10 | 10 | 1.0 | 1.0 | 0.5 | 10 | | 10 | 20 | 1.0 | 1.0 | 0.5 | 11 | | 10 | 30 | 1.0 | 1.0 | 0.7 | 12 | | 20 | 3 | 0.7 | 0.5 | 0.35 | 13 | | 20 | 5 | 0.9 | 0.65 | 0.45 | 14 | | 20 | 10 | 0.95 | 0.9 | 0.45 | 15 | | 20 | 20 | 1.0 | 1.0 | 0.45 | 16 | | 20 | 30 | 1.0 | 1.0 | 0.55 | 17 | | 30 | 3 | 0.5 | 0.4 | 0.3333333333333333 | 18 | | 30 | 5 | 0.7 | 0.5 | 0.43333333333333335 | 19 | | 30 | 10 | 0.8666666666666667 | 0.7666666666666667 | 0.43333333333333335 | 20 | | 30 | 20 | 0.9666666666666667 | 0.9666666666666667 | 0.4666666666666667 | 21 | | 30 | 30 | 1.0 | 0.9666666666666667 | 0.5666666666666667 | 22 | | 50 | 3 | 0.44 | 0.28 | 0.3 | 23 | | 50 | 5 | 0.58 | 0.4 | 0.38 | 24 | | 50 | 10 | 0.74 | 0.6 | 0.38 | 25 | | 50 | 20 | 0.96 | 0.82 | 0.4 | 26 | | 50 | 30 | 0.98 | 0.88 | 0.48 | 27 | | 100 | 3 | 0.3 | 0.2 | 0.23 | 28 | | 100 | 5 | 0.42 | 0.29 | 0.27 | 29 | | 100 | 10 | 0.59 | 0.46 | 0.28 | 30 | | 100 | 20 | 0.78 | 0.67 | 0.32 | 31 | | 100 | 30 | 0.86 | 0.78 | 0.38 | 32 | -------------------------------------------------------------------------------- /evaluation/lalaland_comments/krwordrank_keysent.txt: -------------------------------------------------------------------------------- 1 | 여운이 크게남는영화 엠마스톤 너무 사랑스럽고 라이언고슬링 남자가봐도 정말 매력적인 배우인듯 영상미 음악 연기 구성 전부 좋았고 마지막 엔딩까지 신선하면서 애틋하구요 30중반에 감정이 많이 메말라있었는데 오랜만에 가슴이 촉촉해지네요 2 | 영상미도 너무 아름답고 신나는 음악도 좋았다 마지막 세바스찬과 미아의 눈빛교환은 정말 마음 아팠음 영화관에 고딩들이 엄청 많던데 고딩들은 영화 내용 이해를 못하더라ㅡㅡ사랑을 깊게 해본 사람이라면 누구나 느껴볼수있는 먹먹함이 있다 3 | 정말 영상미랑 음악은 최고였다 그리고 신선했다 음악이 너무 멋있어서 연기를 봐야 할지 노래를 들어야 할지 모를 정도로 그리고 보고 나서 생각 좀 많아진 영화 정말 이 연말에 보기 좋은 영화 인 것 같다 4 | 무언의 마지막 피아노연주 완전 슬픔ㅠ보는이들에게 꿈을 상기시켜줄듯 또 보고 싶은 내생에 최고의 뮤지컬영화였음 단순할수 있는 내용에 뮤지컬을 가미시켜째즈음악과 춤으로 지루할틈없이 빠져서봄 ost너무좋았음 5 | 처음엔 초딩들 보는 그냥 그런영화인줄 알았는데 정말로 눈과 귀가 즐거운 영화였습니다 어찌보면 뻔한 스토리일지 몰라도 그냥 보고 듣는게 즐거운 그러다가 정말 마지막엔 너무 아름답고 슬픈 음악이 되어버린 6 | 정말 멋진 노래와 음악과 영상미까지 정말 너무 멋있는 영화 눈물을 흘리면서 봤습니다 영화가 끝난 순간 감탄과 동시에 여운이 길게 남아 또 눈물을 흘렸던내 인생 최고의 뮤지컬 영화 7 | 평소 뮤지컬 영화 좋아하는 편인데도 평점에 비해 너무나 별로였던 영화 재즈음악이나 영상미 같은 건 좋았지만 줄거리도 글쎄 결말은 정말 별로 6 7점 정도 주는게 맞다고 생각하지만 개인적으로 후반부가 너무 별로여서 8 | 오랜만에 좋은 영화봤다는 생각들었구요 음악도 영상도 스토리도 너무나좋았고 무엇보다 진한 여운이 남는 영화는 정말 오랜만이었어요 연인끼리 가서 보기 정말 좋은영화 너뮤너뮤너뮤 재밌게 잘 봤습니다 9 | 음악 미술 연기 등 모든 것이 좋았지만 마지막 결말이 너무 현실에 뒤떨어진 꿈만 같다 꿈을 이야기하는 영화지만 과정과 결과에 있어 예술가들의 현실을 너무 반영하지 못한 것이 아닌가하는 생각이든다 그래서 보고 난 뒤 나는 꿈을 꿔야하는데 허탈했다 10 | 마지막 회상씬의 감동이 잊혀지질않는다마지막 십분만으로 티켓값이 아깝지않은 영화 음악들도 너무 아름다웠다옛날 뮤지컬 같은 빈티지영상미도 최고 11 | 아 정말 10점만으론 모자르다 배우들 연기 음악 영상 구성 모두 완벽했다 영화 끝나고도 눈물이 멈추지 않아 바로 못 일어났다 여운이 진짜 길게 남는 너무 아름다운 영화 12 | 진짜 이영화는 인생영화 평점에서 노래는 좋은데 줄거리랑 내용이그에비해 안좋다는 사람 있는데 줄거리 음악 시각작으로 보이는색들 너무너무 아름답고 좋아요 눈 귀 모든감각이 이영활보고 호강하는듯해요 영화표가 절대안아까운 인생영화 꼭보세요 13 | 현실적인 척 하는 비현실적인 영화 여주 캐릭터 매력없고 재즈피아노 좋아하는데 처음엔 좋았으나 너무 반복해서 나중엔 듣기 싫을정도 특히 스토리 혐 그렇다고 영상미 음악 뮤지컬 내용 연기 모 하나 뛰어난것 없이 짬뽕스러운 느낌 14 | 연출 음악 영상미 엔딩은 정말 좋았다 마지막에 남녀주인공이 나눈 눈빛이 아직도 잊혀지지않을만큼 여운이 남는 영화였다 초중반 약간 지루하긴했었다 배우들 춤연습을 많이한게보였음 꿈 성공 과 사랑을 다 가질수 없다는것을 현실적으로보여준 영화가아니었나싶다 15 | 아 너무너무 보고싶었던영화재미도 있고 울컥울컥 감동적이었어요피아노에 푹 빠지게 만든 라이언고슬링과사랑스러웠던 엠마스톤 최고 음악과 노래가 계속 생각나구요결말도 16 | 영상 음악 배우들 감정선 너무좋음 ㅠㅠ 그리고 결말은 개인적으로 최고였다고 생각함 결말을 보면서 한 대 얻어맞은 듯한 충격과 여운이 동시에 느껴졌다 진짜 왜 인생영화로 꼽는지 알만함 17 | 색감이 정말 예쁘고 음악이 좋은 영화 한 마디로 눈과 귀가 즐거운 영화였어요 제가 좋아하는 영화 노트북 의 남주가 나와서 더 좋았답니다ㅎㅎ 자주 치던 그 멜로디가 마지막에 너무 슬프게 다가와서 아직도 먹먹하네요 18 | 위플래쉬를너무재밌게봐서기대했는데ㅎ 죽기전에꼭봐야할영화까지는아닌것같아욬ㅋㅋ 중간에는너무지루해서잠이들뻔했습니다 결말도이상했습니다 둘이평생사랑할것같더니장면이바뀌고갑자기5년후로넘어가면서헤어져있고 왜헤어진건지의문입니다 하지만 영상미와음악은아름답고좋았어요 19 | 음악은 정말 좋았는데 스토리의 연결 개연성이 부족해보임 그리고 재즈를 좋아해서 너무 기대하고 갔나보다 딱 뮤지컬영화일뿐 물론 색감예쁘고 음악좋긴하지만 20 | 인생영화다 노래도 너무 좋고 배우 소품 배경 장면들 하나하나 맘에 안 드는게 없다 ㅠㅠ 특히 마지막 셉oo에서의 내용은 진짜 잊을 수가 없을 거같다 보고나면 먹먹하고 안타까운 느낌이 드는데 그래도 황홀하고 아름다운 영화다 21 | 처음시작부터 소름이었고 노래들도 다 너무 좋았고 마지막은 진짜 펑펑울었네요 생각보다 너무너무 좋았어요ㅠㅠ 멋있는영화 많이 만들어주세요 여운과 감동이 많은 영화였어요 또보고싶어요 22 | 너무 좋은 영화 스토리는 비숫한것같아요 그래도 음악 영상 이루어지지않는 사랑을 더 매력적으로 전달한영화인것같아요 보고나서도 여운이 남는 23 | 먼저 음악이 너무 좋고아름다운 영상미만으로도 최고네요 아름답지만 짠내도 나구요 별 생각없이 봤는데 강추입니다 영화보고 계속 음악이 귀에 맴돌아요 24 | 인생영화다 라이언고슬링 노트북도 그렇고 이런 역할 너무 잘어울리고 엠마스톤 너무 사랑스럽고 둘이 탭댄스를 추는 모습은 정말 매력적이다ㅠㅠ 영상미도 이쁘고 재즈노래도 너무 좋고 보는 내내 행복했다 25 | 최고였다 오프닝부터 감탄의 시작 슬픈 노래가 될 줄 몰랐다는 베평에 정말 공감 기쁘고 슬프고 아름다운 영화 음악이 너무 좋다 귓가에 맴돈다 26 | 요근래영화정말많이봤는데 이영화는 진짜 보는내내 사랑스럽고 재밌었어요 음악도아름답고 보통뮤지컬처럼 억지로 음악을 껴넣은듯한느낌이 아니고 진짜 이야기를 듣는거처럼 심야영화라졸릴까아이맥스로봐서 돈아까울까걱정했는데진짜오랜만에 좋은영화봤네요 이거진짜명작이네요 27 | 정말 올해 12개월동안 많은 영화를 봤음에도 불구하고 제일 나중에 본 이 영화가 제일 최고인거 같아요 배우들의 대사 동작 하나하나와 음악 하나하나가 모두 어울려져서 너무 좋았어요 정말 다시 보고 싶은 영화입니다 28 | 마지막 미아와 세바스찬의 표정은 잊을수가 없다 주인공 영상미 음악 노래 다 너무 좋았어요이런영화를 몇천원에 볼수있다는게 새삼 너무 감사해지네요 29 | 음악이며 영상이며 아름다움 그 자체다 영화가 다 끝났는데 또 보고 싶은 이렇게 아름다운 영화를 볼 수 있어서 오늘 너무 행복했다 극장에서 나오자마자 ost를 주문하고 기다리는 중인데 음악 들을 생각을 하니 정말 즐겁다 30 | 진짜로 내 인생영화다 처음엔 정말 로맨틱하게 영화처럼 시작해서 마지막엔 누구보다 현실적이게 끝난다 마지막에 두 사람이 꿈꾸던 행복한 생활들이 스쳐지나갈땐 정말 미친듯이 눈물이 났다 500일의 썸머 봤을때도 이런 느낌이었는데 하 너무 좋다 31 | -------------------------------------------------------------------------------- /evaluation/lalaland_comments/krwordrank_keyword.txt: -------------------------------------------------------------------------------- 1 | 영화 201.02402099523516 2 | 너무 81.53699026386887 3 | 정말 40.53709233921311 4 | 음악 40.43446188536923 5 | 마지막 38.598509495213484 6 | 뮤지컬 23.198810378709844 7 | 최고 21.810147306627464 8 | 사랑 20.638511587426862 9 | 꿈을 20.43744237599688 10 | 아름 20.324710458174806 11 | 영상 20.283994278960186 12 | 여운이 19.471356929084546 13 | 진짜 19.06433920013137 14 | 노래 18.732801785265316 15 | 보고 18.567209814507635 16 | 좋았 17.618296401675455 17 | 그냥 16.55453989963071 18 | 스토리 16.277387894507402 19 | 좋은 15.641013938237343 20 | 인생 15.388030483889121 21 | 현실 15.192677865984141 22 | 생각 14.909981341470491 23 | 지루 13.779469567400135 24 | 다시 13.598127837269505 25 | 감동 13.583235304998885 26 | 보는 12.472406292452346 27 | 좋아 11.982300968832861 28 | 재밌 11.893509552344748 29 | 재미 11.393018919452123 30 | 좋고 11.347360560611962 31 | 계속 11.11730148980459 32 | 느낌 10.994966845465074 33 | 조금 10.989108778236133 34 | 처음 10.747219592444031 35 | 결말 10.583650018377377 36 | 연기 10.501032261398775 37 | 장면 10.347182871319864 38 | 그리고 10.341747951938062 39 | 하는 10.26504947417896 40 | 있는 10.161334369137649 41 | ㅠㅠ 10.083997150887667 42 | 많이 9.88502134642368 43 | 사람 9.56828866113301 44 | 모두 9.20437930833741 45 | 남는 9.055397606763345 46 | 기대 9.05462734235205 47 | 재즈 9.039608940153169 48 | 라이언 8.989819671276129 49 | 연출 8.609603462526849 50 | 눈물이 8.55774030189967 51 | 하지만 8.517077328589066 52 | 모든 8.420648610364522 53 | 이런 8.417182256834185 54 | 봤는데 8.38296980057849 55 | 올해 8.073256597841839 56 | 꿈과 7.746846609633642 57 | 같은 7.700634408754483 58 | 배우 7.603017369953495 59 | of 7.594725554721352 60 | 내내 7.53695939057553 61 | 내가 7.498530259329661 62 | 엔딩 7.4072134060968855 63 | 별로 7.318934792515076 64 | 대한 7.047438309088569 65 | 이렇게 7.0167291314314175 66 | 중간에 6.963881413217753 67 | 평점 6.945384585112542 68 | 라라 6.657944221650013 69 | 가슴 6.5693654715419445 70 | 엠마 6.43578397113738 71 | 그런 6.377235235742933 72 | 내용 6.370564814790385 73 | 오랜만에 6.248428776654158 74 | 보면 6.22596189932411 75 | 이야기 6.188949841483143 76 | 가장 6.161808455856444 77 | 마음 6.144191089226175 78 | 한번 6.135182924070537 79 | 감독 6.134347515367738 80 | 없는 6.101623861161862 81 | ost 6.092266145924688 82 | 아니 6.072295898595082 83 | 함께 6.069789280911886 84 | 10 6.017200088809616 85 | 슬픈 5.994297737701854 86 | 서로 5.906493677504104 87 | 두번 5.834953892602049 88 | 특히 5.827514626781509 89 | 남자 5.787229493962413 90 | 행복 5.752896480038246 91 | 추천 5.749204375349501 92 | 색감 5.7275874335185 93 | 하나 5.660806214142387 94 | ㅎㅎ 5.550178435598377 95 | 않은 5.411746162506662 96 | 봤습니다 5.357411358041111 97 | 피아노 5.299464303756359 98 | 멋진 5.2878403499510345 99 | 약간 5.269304638254184 100 | 많은 5.041519566084218 101 | -------------------------------------------------------------------------------- /evaluation/lalaland_comments/performance.csv: -------------------------------------------------------------------------------- 1 | ,n_keywords,n_keysents,KR-WordRank,TextRank,TextRank(cos) 2 | 0,10,3,0.8,0.6,0.4 3 | 1,10,5,1.0,0.7,0.5 4 | 2,10,10,1.0,1.0,0.5 5 | 3,10,20,1.0,1.0,0.5 6 | 4,10,30,1.0,1.0,0.7 7 | 5,20,3,0.7,0.5,0.35 8 | 6,20,5,0.9,0.65,0.45 9 | 7,20,10,0.95,0.9,0.45 10 | 8,20,20,1.0,1.0,0.45 11 | 9,20,30,1.0,1.0,0.55 12 | 10,30,3,0.5,0.4,0.3333333333333333 13 | 11,30,5,0.7,0.5,0.43333333333333335 14 | 12,30,10,0.8666666666666667,0.7666666666666667,0.43333333333333335 15 | 13,30,20,0.9666666666666667,0.9666666666666667,0.4666666666666667 16 | 14,30,30,1.0,0.9666666666666667,0.5666666666666667 17 | 15,50,3,0.44,0.28,0.3 18 | 16,50,5,0.58,0.4,0.38 19 | 17,50,10,0.74,0.6,0.38 20 | 18,50,20,0.96,0.82,0.4 21 | 19,50,30,0.98,0.88,0.48 22 | 20,100,3,0.3,0.2,0.23 23 | 21,100,5,0.42,0.29,0.27 24 | 22,100,10,0.59,0.46,0.28 25 | 23,100,20,0.78,0.67,0.32 26 | 24,100,30,0.86,0.78,0.38 27 | -------------------------------------------------------------------------------- /evaluation/lalaland_comments/textrank_cos_keysent.txt: -------------------------------------------------------------------------------- 1 | 좋다 좋다 정말 너무 좋다 그 말 밖엔 인생영화 등극 ㅠㅠ 2 | 음악도 좋고 다 좋고 좋고좋고 다 좋고 씁쓸한 결말 뭔가 아쉽다 3 | 제 인생영화 등극이네요 끝나기 전쯤에는 그냥 훌륭한 뮤지컬영화다 라고 생각했는데 마지막에 감독의 메시지가 집약된 화려한 엔딩에서 와 인생영화다 라는생각밖에 안들었네요 개봉하고 2번은 더 보러갈겁니다 4 | 이거 2번보고 3번 보세요 진짜 최고입니다 5 | 너무 아름다운 영화였어요 ㅎ 6 | 나의 인생영화 7 | 벌써 두번째 보는 영화인데요 아무리 봐도 잊혀지지 않네요 8 | 좋아용 은악이너뮤신선하고 9 | 인생영화 두번째봐요 10 | 재밌고 좋았어요굿이에요 11 | 음악이 좋은 영화이다 12 | 재밌어서 두번이나 봤는데 또보고 싶네요 13 | 음악도 좋고 스토리도 좋았고 볼거리가 많은 영화 14 | 재밌지만 재밌지 않으면서 재밌어요 15 | 두번봐도 괜찮을 영화입니다 16 | 노래굳괜찮아어요다음에도 봐요 17 | 인생영화 최고의 영화 18 | 좋은 영화입니다 ㅎㅎㅎㅎ 19 | 살아서 영화를 보는 황홀함 20 | 그러지 않아서 더 좋았던 영화 21 | 황홀하고 따뜻한 꿈이었어요 imax로 또 보려합니다 좋은 영화 시사해주셔서 감사해요 22 | 아름다운 영화입니다 23 | 2번째봅니다 여자친구랑 보시는걸 추천해요 24 | 연인끼리 보기 좋은 영화 25 | 너무 재밌는 인생영화에요 26 | 영화가 이쁘네요ㅋㅋ 부러워요ㅋ 27 | 혼자 보면 영화보는 내내 눈물 흘릴수도 28 | 정말 재밌어요 다시 또 보고싶은 영화예요 29 | 최고다 감동의 영화 좋다 30 | 음악도 좋고 쿨한 엔딩도 좋고 굳입니다 31 | -------------------------------------------------------------------------------- /evaluation/lalaland_comments/textrank_keysent.txt: -------------------------------------------------------------------------------- 1 | 시사회 보고 왔어요 꿈과 사랑에 관한 이야기인데 뭔가 진한 여운이 남는 영화예요 2 | 시사회 갔다왔어요 제가 라이언고슬링팬이라서 하는말이아니고 너무 재밌어요 꿈과 현실이 잘 보여지는영화 사랑스런 영화 전 개봉하면 또 볼생각입니당 3 | 황홀하고 따뜻한 꿈이었어요 imax로 또 보려합니다 좋은 영화 시사해주셔서 감사해요 4 | 시사회에서 보고왔는데 여운쩔었다 엠마스톤과 라이언 고슬링의 케미가 도입부의 강렬한음악좋았고 예고편에 나왓던 오디션 노래 감동적이어서 눈물나왔다ㅠ 이영화는 위플래쉬처럼 꼭 영화관에봐야함 색감 노래 배우 환상적인 영화 5 | 방금 시사회로 봤는데 인생영화 하나 또 탄생했네 롱테이크 촬영이 예술 영상이 넘나 아름답고 라이언고슬링의 멋진 피아노 연주 엠마스톤과의 춤과 노래 눈과 귀가 호강한다 재미를 기대하면 약간 실망할수도 있지만 충분히 훌륭한 영화 6 | 방금 시사회보고 왔어요 정말 힘든 하루였는데 눈이랑 귀가 절로 호강한 영화였어요ㅜ개봉하면 혼자 또 보러갈까해요 마지막에 라이언고슬링의 피아노연주는 아직도 여운이 남네요 뭔가 현실적이여서 더 와닿는 음악영화 좋아하시는분들은 꼭 보시길 7 | ost가 너무좋네요 특히 라이언고슬링이 불르는 노래가 ㄷㄷ 정말 여운이 남아요 8 | 사랑과 꿈 그 흐름의 아름다움을 음악과 영상으로 최대한 담아놓았다 배우들 연기는 두말할것없고 9 | 시사회 갔다왔는데 실망했어요 너무 기대하면 안될 것 같습니다 꿈 같은 영화 마법 같은 영화는 맞는데 꿈과 마법이 깨지는 순간 이 영화는 어디로 가고 있는가 하는 생각이 들었어요 할 말은 많지만 욕먹을까봐 줄임 10 | 오늘 부산국제영화제에서 봤는데영화가 아름답네요 정말 좋은 ost때문에 귀도 호강하네요 개인적으로 엔딩이 너무 좋았던거 같아요 11 | 방금 시사회 보고 왔어요 배우들 매력이 눈을 뗄 수가 없게 만드네요 한편의 그림 같은 장면들도 많고 음악과 춤이 눈과 귀를 사로 잡았어요 한번 더 보고 싶네요 12 | 지금껏 영화 평가 해본 적이 없는데 진짜 최고네요 색감 스토리 음악 연기 모두ㅜㅜ최고입니다 13 | 방금 전 시사회에서 보고 왔습니다 귀와 눈이 모두 즐거운 놀랍고 환상적인 영화입나다 강추합니다 14 | 시사회 봤네요 영화를 보고나면 지금 내 옆에 있는 사람이 소중하게 느껴질것 15 | 저가 본 영화중에서 두번째로 최고인 영화였던것같습니다 노래도너무좋았고 정말 한 장면도 놓칠수없었습니다 재밌었고 앞으로도 이런 비슷한 영화들이나와도 괜찮을것같다 싶었던것같습니다 16 | 이번 부산국제영화제서 봤습니다 정말 역대 100 최고의 영화라 말할수있을 정도로 훌륭하고 신나고 즐겁고 재밌었습니다 두번보고 세번보고 평생소장하고 싶은 영화로 추천합니다 사랑하는 사람과 함께 황홀경에 빠져보시길 17 | 좋다 좋다 정말 너무 좋다 그 말 밖엔 인생영화 등극 ㅠㅠ 18 | 눈과 귀를 사로잡는 라라랜드 영화를 보고 난 후 여운이 많이 남는 영화에요 중간 중간 동화적 요소가 조금 과한듯해서 당황했지만 주인공들의 연기력과 호흡이 정말 좋았던거 같아요 19 | 두번봐도 감동이 전해지는 영화 인생영화라고 칭할 정도로 감동 받았다 음악과 영상미에 가장 먼저 매료되었고 내용도 꿈을 찾아가는 것이 현재 청춘들이 느낄 수 있는 감정들을 충분히 잘 표현했다고 생각한다 본지 꽤 되었지만 한달 째 ost에 빠져사는중 20 | 생각보단 별루엿어요 연출과 음악은 좋았으나 그이상은 아니었습니다 너무 기대하구 봐서 그런것도 같유요 21 | 마지막에 엠마 스톤이랑 헤어져서 너무 슬펐네요 슬프면서도 현실적이라고 할까요 마지막 장면의 라이언고슬링의 피아노 연주는 진짜 여운이 최고였습니다 올해 최고의 영화입니다 22 | 좋네요 내용과 음악 배경들 느끼는게 많아요 23 | 최고의 뮤지컬 영화영화를 보는 내내 꿈 속에 있는듯한 황홀함 24 | 엠마스톤의 노래 솜씨도 보겠군 25 | 정말 좋은 영화네요색감 화면 음악 연기 극본 뭐하나 빠지는게 없는 영화입니다굳이 보러갈만한 가치가 있네요약간 냉소적인 시선 현실적인 엔딩까지 모두 좋습니다다만 뮤지컬영화다 보니 좀 길다싶은 테이크도 있네요 그래도 꼭 한번 볼만한 영화 26 | 정말 더이상 표현할수없을 미쟝센 엠마스톤과 라이언고슬링의 호흡 영화를 보고 한달이 지나도록 머리에 맴도는 음악 그무엇 하나도 완벽하지 않은것이 없고그무엇 하나도 아름답지 않은것이 없다 27 | 이 영화는 여름에 보면 안되겠음 색조합 난리나는 영상미에 취해 자꾸 입이 벌어져 날파리가 들어갈 위험이 있고 사운드에 지려 여름엔 암모니아 냄새가 더 진동할 수 있음 28 | 음악도 내용도 모두 좋아요시끄러운세상에 한줌 단비 같은 ㅎ 29 | 감동과 재미 관객의 마음을 흔들리게 만들 정도록 작품성 연기 노래 춤 모두 브라보 진짜 중간에 박수칠정도록 올해 마지막 최고의 작품입니다 그리고 재즈와 라리언고슬링 매력에 빠졌습니다 30 | 제 인생영화 등극이네요 끝나기 전쯤에는 그냥 훌륭한 뮤지컬영화다 라고 생각했는데 마지막에 감독의 메시지가 집약된 화려한 엔딩에서 와 인생영화다 라는생각밖에 안들었네요 개봉하고 2번은 더 보러갈겁니다 31 | -------------------------------------------------------------------------------- /evaluation/lalaland_comments/textrank_keyword.txt: -------------------------------------------------------------------------------- 1 | 영화/NNG 94.9800918982384 2 | 보/VV 72.61179627608334 3 | 좋/VA 37.31853917572673 4 | 하/VV 28.6936676950679 5 | 것/NNB 25.97887571608593 6 | 같/VA 24.85762696779893 7 | 너무/MAG 24.79712836515808 8 | 음악/NNG 24.41111992354643 9 | 꿈/NNG 22.305292109172292 10 | 있/VV 22.17628718909798 11 | 영화/NNP 20.600813449890147 12 | 없/VA 19.36924810906413 13 | 마지막/NNG 17.747002296005572 14 | 수/NNB 16.150996627821332 15 | 사랑/NNG 15.33083771651521 16 | 아름답/VA 14.697029671608833 17 | 현실/NNG 13.603785084778293 18 | 되/VV 13.22327006880406 19 | 노래/NNG 13.215895467861102 20 | 생각/NNG 12.879590834075861 21 | 정말/MAG 12.539011584620445 22 | 스토리/NNP 11.877181582402859 23 | 잘/MAG 11.538657569373965 24 | 번/NNB 11.353511953316097 25 | 거/NNB 10.995399587080511 26 | 최고/NNG 10.568927465271043 27 | 때/NNG 10.489144683077521 28 | 사람/NNG 10.475094543089424 29 | 더/MAG 10.099195844761075 30 | 여운/NNP 10.091107084410995 31 | 뮤지컬/NNP 9.178661102363709 32 | 나오/VV 9.056858935818772 33 | 영상미/NNG 9.039735450779787 34 | 지루/XR 9.02126354582025 35 | 듯/NNB 8.863846168971007 36 | 장면/NNG 8.470213865855904 37 | 처음/NNG 8.468964479214469 38 | 감동/NNG 8.436566581676026 39 | 가/VV 8.34376858399616 40 | 남/VV 7.6843145119883935 41 | 들/VV 7.415572847557469 42 | 결말/NNG 7.331361578247722 43 | 만들/VV 7.3108657748372465 44 | 중간/NNG 7.300741147926368 45 | 슬프/VA 7.278478052035916 46 | 느낌/NNG 7.234563618637607 47 | 진짜/MAG 7.224577005185221 48 | 말/NNG 7.222862445000504 49 | 기대/NNG 7.012223529467133 50 | 다/MAG 6.982367342037027 51 | 눈물/NNG 6.736285871899242 52 | 재즈/NNP 6.726498709357801 53 | 재밌/VA 6.585507568205498 54 | 좋아하/VV 6.509966959642143 55 | 인생/NNG 6.49491508859647 56 | 느끼/VV 6.402951535375996 57 | 다시/MAG 6.362934137901226 58 | 그냥/MAG 6.330885416392511 59 | 내용/NNG 6.188555020847803 60 | 점/NNB 6.142636652166192 61 | 좀/MAG 6.138157800582213 62 | 딩/MAG 6.122864052551064 63 | 라라/NNP 6.1056821127283465 64 | 평점/NNG 6.088845353263603 65 | 랜드/NNP 6.054090865403703 66 | 모르/VV 5.913758824091814 67 | 나/VV 5.7987591028605285 68 | 대하/VV 5.6522977800301035 69 | 끝나/VV 5.5611138759184096 70 | 엔/NNG 5.512186857815747 71 | 인생/NNP 5.502415392362123 72 | 그리고/MAJ 5.453399928898364 73 | 감독/NNG 5.419365704614821 74 | 고스/NNP 5.387225928083128 75 | 내/NNB 5.342929968336575 76 | 링/NNG 5.318184831082424 77 | 많/VA 5.313643625876339 78 | 그렇/VA 5.257940436402065 79 | 안/MAG 5.221673447026868 80 | 또/MAJ 5.189022314723071 81 | 중/NNB 5.124855780729143 82 | 라이언/NNP 4.969393109266909 83 | 마음/NNG 4.834577724751411 84 | 많이/MAG 4.753510697823062 85 | 부분/NNG 4.5822451564292574 86 | 왜/MAG 4.577157587079595 87 | 이렇/VA 4.5767674741716755 88 | 눈/NNG 4.548456561844402 89 | 시간/NNG 4.491065697393249 90 | 감정/NNG 4.459084762475801 91 | 가슴/NNG 4.432490066667185 92 | 알/VV 4.425435171896243 93 | 남자/NNG 4.297127658160181 94 | 행복/NNG 4.255288436960494 95 | 꼭/MAG 4.2400409804711945 96 | 빠지/VV 4.208984034739151 97 | 오/VV 4.1843048576761985 98 | 이야기/NNG 4.182564858312164 99 | 배우/NNG 4.172329791406558 100 | 분/NNB 4.117485108354423 101 | -------------------------------------------------------------------------------- /krwordrank/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import warnings 3 | if sys.version_info.major < 3: 4 | warnings.warn('Some functions may not work. We recommend python >= 3.5+') 5 | 6 | from . import graph 7 | from . import hangle 8 | from . import sentence 9 | from . import word 10 | -------------------------------------------------------------------------------- /krwordrank/about.py: -------------------------------------------------------------------------------- 1 | __title__ = 'KRWordRank' 2 | __version__ = '1.0.3' 3 | __author__ = 'Lovit' 4 | __license__ = 'LGPL' 5 | __copyright__ = 'Copyright 2017 Lovit' 6 | -------------------------------------------------------------------------------- /krwordrank/graph/__init__.py: -------------------------------------------------------------------------------- 1 | from ._rank import hits 2 | -------------------------------------------------------------------------------- /krwordrank/graph/_rank.py: -------------------------------------------------------------------------------- 1 | def hits(graph, beta, max_iter=50, bias=None, verbose=True, 2 | sum_weight=100, number_of_nodes=None, converge=0.001): 3 | """ 4 | It trains rank of node using HITS algorithm. 5 | 6 | Arguments 7 | --------- 8 | graph : dict of dict 9 | Adjacent subword graph. graph[int][int] = float 10 | beta : float 11 | PageRank damping factor 12 | max_iter : int 13 | Maximum number of iterations 14 | bias : None or dict 15 | Bias vector 16 | verbose : Boolean 17 | If True, it shows training progress. 18 | sum_weight : float 19 | Sum of weights of all nodes in graph 20 | number_of_nodes : None or int 21 | Number of nodes in graph 22 | converge : float 23 | Minimum rank difference between previous step and current step. 24 | If the difference is smaller than converge, it do early-stop. 25 | 26 | Returns 27 | ------- 28 | rank : dict 29 | Rank dictionary formed as {int:float}. 30 | """ 31 | 32 | if not bias: 33 | bias = {} 34 | if not number_of_nodes: 35 | number_of_nodes = max(len(graph), len(bias)) 36 | 37 | if number_of_nodes <= 1: 38 | raise ValueError( 39 | 'The graph should consist of at least two nodes\n', 40 | 'The node size of inserted graph is %d' % number_of_nodes 41 | ) 42 | 43 | dw = sum_weight / number_of_nodes 44 | rank = {node:dw for node in graph.keys()} 45 | 46 | for num_iter in range(1, max_iter + 1): 47 | rank_ = _update(rank, graph, bias, dw, beta) 48 | diff = sum((abs(w - rank.get(n, 0)) for n, w in rank_.items())) 49 | rank = rank_ 50 | 51 | if diff < sum_weight * converge: 52 | if verbose: 53 | print('\riter = %d Early stopped.' % num_iter, end='', flush=True) 54 | break 55 | 56 | if verbose: 57 | print('\riter = %d' % num_iter, end='', flush=True) 58 | 59 | if verbose: 60 | print('\rdone') 61 | 62 | return rank 63 | 64 | def _update(rank, graph, bias, dw, beta): 65 | rank_new = {} 66 | for to_node, from_dict in graph.items(): 67 | rank_new[to_node] = sum([w * rank[from_node] for from_node, w in from_dict.items()]) 68 | rank_new[to_node] = beta * rank_new[to_node] + (1 - beta) * bias.get(to_node, dw) 69 | return rank_new -------------------------------------------------------------------------------- /krwordrank/hangle/__init__.py: -------------------------------------------------------------------------------- 1 | from ._hangle import normalize 2 | from ._hangle import initialize_pattern 3 | -------------------------------------------------------------------------------- /krwordrank/hangle/_hangle.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | 4 | 5 | korean_pattern_str = '가-힣' 6 | number_pattern_str = '0-9' 7 | alphabet_pattern_str = 'a-zA-Z' 8 | puntuation_pattern_str = '.,?!' 9 | 10 | doublespace_pattern = re.compile(r'\s+') 11 | repeatchars_pattern = re.compile(r'(\w)\\1{3,}') 12 | 13 | def normalize(doc, english=False, number=False, punctuation=False, 14 | remove_repeat=0, remains=None, pattern=None): 15 | """ 16 | Arguments 17 | --------- 18 | doc : str 19 | Input string to be normalized 20 | english : Boolean 21 | If True, it remains alphabet 22 | number : Boolean 23 | If True, it remains number 24 | punctuation : Boolean 25 | If True, it remains symbols '.,?!' 26 | remove_repeat : int 27 | If it is positive integer, it shortens repeated characters. 28 | remains : None or str 29 | User specfied characters that user wants to remain 30 | pattern : None or re.Pattern 31 | User specified regular expression pattern to use for normalization. 32 | For example, to remain Korean and alphabets, 33 | 34 | >>> patterm = re.compile('[^가-힣a-zA-Z]') 35 | 36 | Returns 37 | ------- 38 | doc : str 39 | Normalized string 40 | """ 41 | 42 | if sys.version_info.major >= 3 and sys.version_info.minor <= 6: 43 | if not isinstance(pattern, re._pattern_type): 44 | pattern = initialize_pattern(english, number, punctuation, remains) 45 | elif sys.version_info.major >= 3 and sys.version_info.minor >= 7: 46 | if not isinstance(pattern, re.Pattern): 47 | pattern = initialize_pattern(english, number, punctuation, remains) 48 | else: 49 | if not isinstance(pattern, re.Pattern): 50 | pattern = initialize_pattern(english, number, punctuation, remains) 51 | 52 | if remove_repeat > 0: 53 | doc = repeatchars_pattern.sub('\\1' * remove_repeat, doc) 54 | 55 | doc = pattern.sub(' ', doc) 56 | return doublespace_pattern.sub(' ', doc).strip() 57 | 58 | def initialize_pattern(english=False, number=False, punctuation=False, remains=None): 59 | """ 60 | Arguments 61 | --------- 62 | english : Boolean 63 | If True, it remains alphabet 64 | number : Boolean 65 | If True, it remains number 66 | punctuation : Boolean 67 | If True, it remains symbols '.,?!' 68 | remains : None or str 69 | User specfied characters that user wants to remain 70 | 71 | Returns 72 | ------- 73 | pattern : re.Pattern 74 | Regular expression pattern 75 | 76 | Usage 77 | ----- 78 | >>> initialize_pattern(english=True) 79 | $ re.compile(r'[^가-힣a-zA-Z]', re.UNICODE) 80 | """ 81 | 82 | pattern = korean_pattern_str 83 | if english: 84 | pattern += alphabet_pattern_str 85 | if number: 86 | pattern += number_pattern_str 87 | if punctuation: 88 | pattern += puntuation_pattern_str 89 | if isinstance(remains, str): 90 | pattern += remains 91 | return re.compile(r'[^%s]' % pattern) 92 | -------------------------------------------------------------------------------- /krwordrank/sentence/__init__.py: -------------------------------------------------------------------------------- 1 | from ._tokenizer import MaxScoreTokenizer 2 | from ._sentence import KeywordVectorizer 3 | from ._sentence import keysentence 4 | from ._sentence import make_vocab_score 5 | from ._sentence import summarize_with_sentences 6 | -------------------------------------------------------------------------------- /krwordrank/sentence/_sentence.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from scipy.sparse import csr_matrix 3 | from sklearn.metrics import pairwise_distances 4 | 5 | from ._tokenizer import MaxScoreTokenizer 6 | from ..word import KRWordRank 7 | 8 | 9 | class KeywordVectorizer: 10 | """ 11 | Arguments 12 | --------- 13 | tokenize : callable 14 | Input format is str, output format is list of str (list of terms) 15 | vocab_score : dict 16 | {str:float} form keyword vector 17 | 18 | Attributes 19 | ---------- 20 | tokenize : callable 21 | Tokenizer function 22 | idx_to_vocab : list of str 23 | Vocab list 24 | vocab_to_idx : dict 25 | {str:int} Vocab to index mapper 26 | keyword_vector : numpy.ndarray 27 | shape (len(idx_to_vocab),) vector 28 | """ 29 | 30 | def __init__(self, tokenize, vocab_score): 31 | self.tokenize = tokenize 32 | self.idx_to_vocab = [vocab for vocab in sorted(vocab_score, key=lambda x:-vocab_score[x])] 33 | self.vocab_to_idx = {vocab:idx for idx, vocab in enumerate(self.idx_to_vocab)} 34 | self.keyword_vector = np.asarray( 35 | [score for _, score in sorted(vocab_score.items(), key=lambda x:-x[1])]) 36 | self.keyword_vector = self._L2_normalize(self.keyword_vector) 37 | 38 | def _L2_normalize(self, vectors): 39 | return vectors / np.sqrt((vectors ** 2).sum()) 40 | 41 | def vectorize(self, sents): 42 | """ 43 | Argument 44 | -------- 45 | sents : list of str 46 | Each str is sentence 47 | 48 | Returns 49 | ------- 50 | scipy.sparse.csr_matrix 51 | (n sents, n keywords) shape Boolean matrix 52 | """ 53 | rows, cols, data = [], [], [] 54 | for i, sent in enumerate(sents): 55 | terms = set(self.tokenize(sent)) 56 | for term in terms: 57 | j = self.vocab_to_idx.get(term, -1) 58 | if j == -1: 59 | continue 60 | rows.append(i) 61 | cols.append(j) 62 | data.append(1) 63 | n_docs = len(sents) 64 | n_terms = len(self.idx_to_vocab) 65 | return csr_matrix((data, (rows, cols)), shape=(n_docs, n_terms)) 66 | 67 | 68 | def summarize_with_sentences(texts, num_keywords=100, num_keysents=10, diversity=0.3, stopwords=None, scaling=None, 69 | penalty=None, min_count=5, max_length=10, beta=0.85, max_iter=10, num_rset=-1, verbose=False, bias=None, return_indices=False): 70 | """ 71 | It train KR-WordRank to extract keywords and selects key-sentences to summzriaze inserted texts. 72 | 73 | >>> from krwordrank.sentence import summarize_with_sentences 74 | 75 | >>> texts = [] # list of str 76 | >>> keywords, sents = summarize_with_sentences(texts, num_keywords=100, num_keysents=10) 77 | 78 | Arguments 79 | --------- 80 | texts : list of str 81 | Each str is a sentence. 82 | num_keywords : int 83 | Number of keywords extracted from KR-WordRank 84 | Default is 100. 85 | num_keysents : int 86 | Number of key-sentences selected from keyword vector maching 87 | Default is 10. 88 | diversity : float 89 | Minimum cosine distance between top ranked sentence and others. 90 | Large value makes this function select various sentence. 91 | The value must be [0, 1] 92 | stopwords : None or set of str 93 | Stopwords list for keyword and key-sentence extraction 94 | scaling : callable 95 | Ranking transform function. 96 | scaling(float) = float 97 | Default is lambda x:np.sqrt(x) 98 | penalty : callable 99 | Penalty function. str -> float 100 | Default is no penalty 101 | If you use only sentence whose length is in [25, 40], 102 | set penalty like following example. 103 | 104 | >>> penalty = lambda x: 0 if 25 <= len(x) <= 40 else 1 105 | 106 | min_count : int 107 | Minimum frequency of subwords used to construct subword graph 108 | Default is 5 109 | max_length : int 110 | Maximum length of subwords used to construct subword graph 111 | Default is 10 112 | beta : float 113 | PageRank damping factor. 0 < beta < 1 114 | Default is 0.85 115 | max_iter : int 116 | Maximum number of iterations of HITS algorithm. 117 | Default is 10 118 | num_rset : int 119 | Number of R set words sorted by rank. It will be used to L-part word filtering. 120 | Default is -1. 121 | verbose : Boolean 122 | If True, it shows training status 123 | Default is False 124 | 125 | Returns 126 | ------- 127 | keysentences : list of str 128 | 129 | Usage 130 | ----- 131 | >>> from krwordrank.sentence import summarize_with_sentences 132 | 133 | >>> texts = [] # list of str 134 | >>> keywords, sents = summarize_with_sentences(texts, num_keywords=100, num_keysents=10) 135 | """ 136 | 137 | # train KR-WordRank 138 | wordrank_extractor = KRWordRank( 139 | min_count = min_count, 140 | max_length = max_length, 141 | verbose = verbose 142 | ) 143 | 144 | num_keywords_ = num_keywords 145 | if stopwords is not None: 146 | num_keywords_ += len(stopwords) 147 | 148 | keywords, rank, graph = wordrank_extractor.extract(texts, 149 | beta, max_iter, num_keywords=num_keywords_, num_rset=num_rset, bias=bias) 150 | 151 | # build tokenizer 152 | if scaling is None: 153 | scaling = lambda x:np.sqrt(x) 154 | if stopwords is None: 155 | stopwords = {} 156 | vocab_score = make_vocab_score(keywords, stopwords, scaling=scaling, topk=num_keywords) 157 | tokenizer = MaxScoreTokenizer(scores=vocab_score) 158 | 159 | # find key-sentences 160 | if return_indices is True: 161 | sents, idxs = keysentence(vocab_score, texts, tokenizer.tokenize, num_keysents, diversity, penalty, return_indices=return_indices) 162 | keywords_ = {vocab:keywords[vocab] for vocab in vocab_score} 163 | return keywords_, sents, idxs 164 | else: 165 | sents = keysentence(vocab_score, texts, tokenizer.tokenize, num_keysents, diversity, penalty, return_indices=return_indices) 166 | keywords_ = {vocab:keywords[vocab] for vocab in vocab_score} 167 | return keywords_, sents 168 | 169 | def keysentence(vocab_score, texts, tokenize, topk=10, diversity=0.3, penalty=None, return_indices=False): 170 | """ 171 | Arguments 172 | --------- 173 | keywords : {str:int} 174 | {word:rank} trained from KR-WordRank. 175 | texts will be tokenized using keywords 176 | texts : list of str 177 | Each str is a sentence. 178 | tokenize : callble 179 | Tokenize function. Input form is str and output form is list of str 180 | topk : int 181 | Number of key sentences 182 | diversity : float 183 | Minimum cosine distance between top ranked sentence and others. 184 | Large value makes this function select various sentence. 185 | The value must be [0, 1] 186 | penalty : callable 187 | Penalty function. str -> float 188 | Default is no penalty 189 | If you use only sentence whose length is in [25, 40], 190 | set penalty like following example. 191 | 192 | >>> penalty = lambda x: 0 if 25 <= len(x) <= 40 else 1 193 | 194 | Returns 195 | ------- 196 | keysentences : list of str 197 | """ 198 | if not callable(penalty): 199 | penalty = lambda x: 0 200 | 201 | if not 0 <= diversity <= 1: 202 | raise ValueError('Diversity must be [0, 1] float value') 203 | 204 | vectorizer = KeywordVectorizer(tokenize, vocab_score) 205 | x = vectorizer.vectorize(texts) 206 | keyvec = vectorizer.keyword_vector.reshape(1,-1) 207 | initial_penalty = np.asarray([penalty(sent) for sent in texts]) 208 | idxs = select(x, keyvec, texts, initial_penalty, topk, diversity) 209 | if return_indices is True: 210 | return [texts[idx] for idx in idxs], idxs 211 | else : 212 | return [texts[idx] for idx in idxs] 213 | 214 | def select(x, keyvec, texts, initial_penalty, topk=10, diversity=0.3): 215 | """ 216 | Arguments 217 | --------- 218 | x : scipy.sparse.csr_matrix 219 | (n docs, n keywords) Boolean matrix 220 | keyvec : numpy.ndarray 221 | (1, n keywords) rank vector 222 | texts : list of str 223 | Each str is a sentence 224 | initial_penalty : numpy.ndarray 225 | (n docs,) shape. Defined from penalty function 226 | topk : int 227 | Number of key sentences 228 | diversity : float 229 | Minimum cosine distance between top ranked sentence and others. 230 | Large value makes this function select various sentence. 231 | The value must be [0, 1] 232 | 233 | Returns 234 | ------- 235 | keysentence indices : list of int 236 | The length of keysentences is topk at most. 237 | """ 238 | 239 | dist = pairwise_distances(x, keyvec, metric='cosine').reshape(-1) 240 | dist = dist + initial_penalty 241 | 242 | idxs = [] 243 | for _ in range(topk): 244 | idx = dist.argmin() 245 | idxs.append(idx) 246 | dist[idx] += 2 # maximum distance of cosine is 2 247 | idx_all_distance = pairwise_distances( 248 | x, x[idx].reshape(1,-1), metric='cosine').reshape(-1) 249 | penalty = np.zeros(idx_all_distance.shape[0]) 250 | penalty[np.where(idx_all_distance < diversity)[0]] = 2 251 | dist += penalty 252 | return idxs 253 | 254 | def make_vocab_score(keywords, stopwords, negatives=None, scaling=lambda x:x, topk=100): 255 | """ 256 | Arguments 257 | --------- 258 | keywords : dict 259 | {str:float} word to rank mapper that trained from KR-WordRank 260 | stopwords : set or dict of str 261 | Stopword set 262 | negatives : dict or None 263 | Penalty term set 264 | scaling : callable 265 | number to number. It re-scale rank value of keywords. 266 | topk : int 267 | Number of keywords 268 | 269 | Returns 270 | ------- 271 | keywords_ : dict 272 | Refined word to score mapper 273 | """ 274 | if negatives is None: 275 | negatives = {} 276 | keywords_ = {} 277 | for word, rank in sorted(keywords.items(), key=lambda x:-x[1]): 278 | if len(keywords_) >= topk: 279 | break 280 | if word in stopwords: 281 | continue 282 | if word in negatives: 283 | keywords_[word] = negative[word] 284 | else: 285 | keywords_[word] = scaling(rank) 286 | return keywords_ 287 | 288 | def highlight_keyword(sent, keywords): 289 | for keyword, score in keywords.items(): 290 | if score > 0: 291 | sent = sent.replace(keyword, '[%s]' % keyword) 292 | return sent 293 | -------------------------------------------------------------------------------- /krwordrank/sentence/_tokenizer.py: -------------------------------------------------------------------------------- 1 | class MaxScoreTokenizer: 2 | """ 3 | Transplanted from soynlp.tokenizer.MaxScoreTokenizer 4 | 5 | >>> word_score = {'term':0.8, ...} 6 | >>> tokenizer = MaxScoreTokenizer(word_score) 7 | >>> tokenizer.tokenize('Example sentence') 8 | """ 9 | 10 | def __init__(self, scores=None, max_length=10, default_score=0.0): 11 | self._scores = scores if scores else {} 12 | self._max_length = max_length 13 | self._ds = default_score 14 | 15 | def __call__(self, sentence, flatten=True): 16 | return self.tokenize(sentence, flatten) 17 | 18 | def tokenize(self, sentence, flatten=True): 19 | tokens = [self._recursive_tokenize(token) for token in sentence.split()] 20 | if flatten: 21 | tokens = [subtoken[0] for token in tokens for subtoken in token] 22 | return tokens 23 | 24 | def _recursive_tokenize(self, token, range_l=0, debug=False): 25 | 26 | length = len(token) 27 | if length <= 2: 28 | return [(token, 0, length, self._ds, length)] 29 | 30 | if range_l == 0: 31 | range_l = min(self._max_length, length) 32 | 33 | scores = self._initialize(token, range_l, length) 34 | if debug: 35 | pprint(scores) 36 | 37 | result = self._find(scores) 38 | 39 | adds = self._add_inter_subtokens(token, result) 40 | 41 | if result[-1][2] != length: 42 | adds += self._add_last_subtoken(token, result) 43 | 44 | if result[0][1] != 0: 45 | adds += self._add_first_subtoken(token, result) 46 | 47 | return sorted(result + adds, key=lambda x:x[1]) 48 | 49 | def _initialize(self, token, range_l, length): 50 | scores = [] 51 | for b in range(0, length - 1): 52 | for r in range(2, range_l + 1): 53 | e = b + r 54 | 55 | if e > length: 56 | continue 57 | 58 | subtoken = token[b:e] 59 | score = self._scores.get(subtoken, self._ds) 60 | scores.append((subtoken, b, e, score, r)) 61 | 62 | return sorted(scores, key=lambda x:(-x[3], -x[4], x[1])) 63 | 64 | def _find(self, scores): 65 | result = [] 66 | num_iter = 0 67 | 68 | while scores: 69 | word, b, e, score, r = scores.pop(0) 70 | result.append((word, b, e, score, r)) 71 | 72 | if not scores: 73 | break 74 | 75 | removals = [] 76 | for i, (_1, b_, e_, _2, _3) in enumerate(scores): 77 | if (b_ < e and b < e_) or (b_ < e and e_ > b): 78 | removals.append(i) 79 | 80 | for i in reversed(removals): 81 | del scores[i] 82 | 83 | num_iter += 1 84 | if num_iter > 100: break 85 | 86 | return sorted(result, key=lambda x:x[1]) 87 | 88 | def _add_inter_subtokens(self, token, result): 89 | adds = [] 90 | for i, base in enumerate(result[:-1]): 91 | if base[2] == result[i+1][1]: 92 | continue 93 | 94 | b = base[2] 95 | e = result[i+1][1] 96 | subtoken = token[b:e] 97 | adds.append((subtoken, b, e, self._ds, e - b)) 98 | 99 | return adds 100 | 101 | def _add_first_subtoken(self, token, result): 102 | e = result[0][1] 103 | subtoken = token[0:e] 104 | score = self._scores.get(subtoken, self._ds) 105 | return [(subtoken, 0, e, score, e)] 106 | 107 | def _add_last_subtoken(self, token, result): 108 | b = result[-1][2] 109 | subtoken = token[b:] 110 | score = self._scores.get(subtoken, self._ds) 111 | return [(subtoken, b, len(token), score, len(subtoken))] 112 | -------------------------------------------------------------------------------- /krwordrank/word/__init__.py: -------------------------------------------------------------------------------- 1 | from ._word import summarize_with_keywords 2 | from ._word import KRWordRank 3 | -------------------------------------------------------------------------------- /krwordrank/word/_word.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | import math 3 | import numpy as np 4 | 5 | from krwordrank.graph import hits 6 | 7 | 8 | def summarize_with_keywords(texts, num_keywords=100, stopwords=None, min_count=5, 9 | max_length=10, beta=0.85, max_iter=10, num_rset=-1, verbose=False): 10 | """ 11 | It train KR-WordRank to extract keywords from texts. 12 | 13 | >>> from krwordrank.word import summarize_with_keywords 14 | 15 | >>> texts = [] # list of str 16 | >>> keywords = summarize_with_keywords(texts, num_keywords=100, min_count=5) 17 | 18 | Arguments 19 | --------- 20 | texts : list of str 21 | Each str is a sentence. 22 | num_keywords : int 23 | Number of keywords extracted from KR-WordRank 24 | Default is 100. 25 | stopwords : None or set of str 26 | Stopwords list for keyword and key-sentence extraction 27 | min_count : int 28 | Minimum frequency of subwords used to construct subword graph 29 | Default is 5 30 | max_length : int 31 | Maximum length of subwords used to construct subword graph 32 | Default is 10 33 | beta : float 34 | PageRank damping factor. 0 < beta < 1 35 | Default is 0.85 36 | max_iter : int 37 | Maximum number of iterations of HITS algorithm. 38 | Default is 10 39 | num_rset : int 40 | Number of R set words sorted by rank. It will be used to L-part word filtering. 41 | Default is -1. 42 | verbose : Boolean 43 | If True, it shows training status 44 | Default is False 45 | 46 | Returns 47 | ------- 48 | keywords : dict 49 | Word : rank dictionary. keywords[str] = float 50 | 51 | Usage 52 | ----- 53 | >>> from krwordrank.word import summarize_with_keywords 54 | 55 | >>> texts = [] # list of str 56 | >>> keywords = summarize_with_keywords(texts, num_keywords=100, min_count=5) 57 | """ 58 | # train KR-WordRank 59 | wordrank_extractor = KRWordRank( 60 | min_count = min_count, 61 | max_length = max_length, 62 | verbose = verbose 63 | ) 64 | 65 | keywords, rank, graph = wordrank_extractor.extract(texts, 66 | beta, max_iter, num_rset=num_rset) 67 | 68 | # stopword filtering 69 | if stopwords is None: 70 | stopwords = {} 71 | keywords = {word:r for word, r in keywords.items() if not (word in stopwords)} 72 | 73 | # top rank filtering 74 | if num_keywords > 0: 75 | keywords = {word:r for word, r in sorted(keywords.items(), key=lambda x:-x[1])[:num_keywords]} 76 | 77 | return keywords 78 | 79 | 80 | class KRWordRank: 81 | """Unsupervised Korean Keyword Extractor 82 | 83 | Implementation of Kim, H. J., Cho, S., & Kang, P. (2014). KR-WordRank: 84 | An Unsupervised Korean Word Extraction Method Based on WordRank. 85 | Journal of Korean Institute of Industrial Engineers, 40(1), 18-33. 86 | 87 | Arguments 88 | --------- 89 | min_count : int 90 | Minimum frequency of subwords used to construct subword graph 91 | Default is 5 92 | max_length : int 93 | Maximum length of subwords used to construct subword graph 94 | Default is 10 95 | verbose : Boolean 96 | If True, it shows training status 97 | Default is False 98 | 99 | Usage 100 | ----- 101 | >>> from krwordrank.word import KRWordRank 102 | 103 | >>> texts = ['예시 문장 입니다', '여러 문장의 list of str 입니다', ... ] 104 | >>> wordrank_extractor = KRWordRank() 105 | >>> keywords, rank, graph = wordrank_extractor.extract(texts, beta, max_iter, verbose) 106 | """ 107 | def __init__(self, min_count=5, max_length=10, verbose=False): 108 | self.min_count = min_count 109 | self.max_length = max_length 110 | self.verbose = verbose 111 | self.sum_weight = 1 112 | self.vocabulary = {} 113 | self.index2vocab = [] 114 | 115 | def scan_vocabs(self, docs): 116 | """ 117 | It scans subwords positioned of left-side (L) and right-side (R) of words. 118 | After scanning was done, KR-WordRank has index2vocab as class attribute. 119 | 120 | Arguments 121 | --------- 122 | docs : list of str 123 | Sentence list 124 | 125 | Returns 126 | ------- 127 | counter : dict 128 | {(subword, 'L')] : frequency} 129 | """ 130 | self.vocabulary = {} 131 | if self.verbose: 132 | print('scan vocabs ... ') 133 | 134 | counter = {} 135 | for doc in docs: 136 | 137 | for token in doc.split(): 138 | len_token = len(token) 139 | counter[(token, 'L')] = counter.get((token, 'L'), 0) + 1 140 | 141 | for e in range(1, min(len(token), self.max_length)): 142 | if (len_token - e) > self.max_length: 143 | continue 144 | 145 | l_sub = (token[:e], 'L') 146 | r_sub = (token[e:], 'R') 147 | counter[l_sub] = counter.get(l_sub, 0) + 1 148 | counter[r_sub] = counter.get(r_sub, 0) + 1 149 | 150 | counter = {token:freq for token, freq in counter.items() if freq >= self.min_count} 151 | for token, _ in sorted(counter.items(), key=lambda x:x[1], reverse=True): 152 | self.vocabulary[token] = len(self.vocabulary) 153 | 154 | self._build_index2vocab() 155 | 156 | if self.verbose: 157 | print('num vocabs = %d' % len(counter)) 158 | return counter 159 | 160 | def _build_index2vocab(self): 161 | self.index2vocab = [vocab for vocab, index in sorted(self.vocabulary.items(), key=lambda x:x[1])] 162 | self.sum_weight = len(self.index2vocab) 163 | 164 | def extract(self, docs, beta=0.85, max_iter=10, num_keywords=-1, 165 | num_rset=-1, vocabulary=None, bias=None, rset=None): 166 | """ 167 | It constructs word graph and trains ranks of each node using HITS algorithm. 168 | After training it selects suitable subwords as words. 169 | 170 | Arguments 171 | --------- 172 | docs : list of str 173 | Sentence list. 174 | beta : float 175 | PageRank damping factor. 0 < beta < 1 176 | Default is 0.85 177 | max_iter : int 178 | Maximum number of iterations of HITS algorithm. 179 | Default is 10 180 | num_keywords : int 181 | Number of keywords sorted by rank. 182 | Default is -1. If the vaule is negative, it returns all extracted words. 183 | num_rset : int 184 | Number of R set words sorted by rank. It will be used to L-part word filtering. 185 | Default is -1. 186 | vocabulary : None or dict 187 | User specified vocabulary to index mapper 188 | bias : None or dict 189 | User specified HITS bias term 190 | rset : None or dict 191 | User specfied R set 192 | 193 | Returns 194 | ------- 195 | keywords : dict 196 | word : rank dictionary. {str:float} 197 | rank : dict 198 | subword : rank dictionary. {int:float} 199 | graph : dict of dict 200 | Adjacent subword graph. {int:{int:float}} 201 | 202 | Usage 203 | ----- 204 | >>> from krwordrank.word import KRWordRank 205 | 206 | >>> texts = ['예시 문장 입니다', '여러 문장의 list of str 입니다', ... ] 207 | >>> wordrank_extractor = KRWordRank() 208 | >>> keywords, rank, graph = wordrank_extractor.extract(texts, beta, max_iter, verbose) 209 | """ 210 | 211 | rank, graph = self.train(docs, beta, max_iter, vocabulary, bias) 212 | 213 | lset = {self.int2token(idx)[0]:r for idx, r in rank.items() if self.int2token(idx)[1] == 'L'} 214 | if not rset: 215 | rset = {self.int2token(idx)[0]:r for idx, r in rank.items() if self.int2token(idx)[1] == 'R'} 216 | 217 | if num_rset > 0: 218 | rset = {token:r for token, r in sorted(rset.items(), key=lambda x:-x[1])[:num_rset]} 219 | 220 | keywords = self._select_keywords(lset, rset) 221 | keywords = self._filter_compounds(keywords) 222 | keywords = self._filter_subtokens(keywords) 223 | 224 | if num_keywords > 0: 225 | keywords = {token:r for token, r in sorted(keywords.items(), key=lambda x:-x[1])[:num_keywords]} 226 | 227 | return keywords, rank, graph 228 | 229 | def _select_keywords(self, lset, rset): 230 | keywords = {} 231 | for word, r in sorted(lset.items(), key=lambda x:x[1], reverse=True): 232 | len_word = len(word) 233 | if len_word == 1: 234 | continue 235 | 236 | is_compound = False 237 | for e in range(2, len_word): 238 | if (word[:e] in keywords) and (word[:e] in rset): 239 | is_compound = True 240 | break 241 | 242 | if not is_compound: 243 | keywords[word] = r 244 | 245 | return keywords 246 | 247 | def _filter_compounds(self, keywords): 248 | keywords_= {} 249 | for word, r in sorted(keywords.items(), key=lambda x:x[1], reverse=True): 250 | len_word = len(word) 251 | 252 | if len_word <= 2: 253 | keywords_[word] = r 254 | continue 255 | 256 | if len_word == 3: 257 | if word[:2] in keywords_: 258 | continue 259 | 260 | is_compound = False 261 | for e in range(2, len_word - 1): 262 | # fixed. comment from Y. cho 263 | if (word[:e] in keywords) and (word[e:] in keywords): 264 | is_compound = True 265 | break 266 | 267 | if not is_compound: 268 | keywords_[word] = r 269 | 270 | return keywords_ 271 | 272 | def _filter_subtokens(self, keywords): 273 | subtokens = set() 274 | keywords_ = {} 275 | 276 | for word, r in sorted(keywords.items(), key=lambda x:x[1], reverse=True): 277 | subs = {word[:e] for e in range(2, len(word)+1)} 278 | 279 | is_subtoken = False 280 | for sub in subs: 281 | if sub in subtokens: 282 | is_subtoken = True 283 | break 284 | 285 | if not is_subtoken: 286 | keywords_[word] = r 287 | subtokens.update(subs) 288 | 289 | return keywords_ 290 | 291 | def train(self, docs, beta=0.85, max_iter=10, vocabulary=None, bias=None): 292 | """ 293 | It constructs word graph and trains ranks of each node using HITS algorithm. 294 | Use this function only when you want to train rank of subwords 295 | 296 | Arguments 297 | --------- 298 | docs : list of str 299 | Sentence list. 300 | beta : float 301 | PageRank damping factor. 0 < beta < 1 302 | Default is 0.85 303 | max_iter : int 304 | Maximum number of iterations of HITS algorithm. 305 | Default is 10 306 | vocabulary : None or dict 307 | User specified vocabulary to index mapper 308 | bias : None or dict 309 | User specified HITS bias term 310 | {str: float} Format 311 | 312 | Returns 313 | ------- 314 | rank : dict 315 | subword : rank dictionary. {int:float} 316 | graph : dict of dict 317 | Adjacent subword graph. {int:{int:float}} 318 | """ 319 | if (not vocabulary) and (not self.vocabulary): 320 | self.scan_vocabs(docs) 321 | elif (not vocabulary): 322 | self.vocabulary = vocabulary 323 | self._build_index2vocab() 324 | 325 | graph = self._construct_word_graph(docs) 326 | 327 | # add custom bias dict 328 | encoded_bias = {} 329 | custom_bias_dict = bias 330 | 331 | if custom_bias_dict: 332 | for word, value in custom_bias_dict.items(): 333 | encoded_word = self.token2int((word, 'L')) 334 | if encoded_word != -1: 335 | encoded_bias[encoded_word] = value 336 | 337 | rank = hits(graph, beta, max_iter, encoded_bias, 338 | sum_weight=self.sum_weight, 339 | number_of_nodes=len(self.vocabulary), 340 | verbose=self.verbose 341 | ) 342 | 343 | return rank, graph 344 | 345 | def token2int(self, token): 346 | """ 347 | Arguments 348 | --------- 349 | token : tuple 350 | (subword, 'L') or (subword, 'R') 351 | For example, ('이것', 'L') or ('은', 'R') 352 | 353 | Returns 354 | ------- 355 | index : int 356 | Corresponding index 357 | If it is unknown, it returns -1 358 | """ 359 | return self.vocabulary.get(token, -1) 360 | 361 | def int2token(self, index): 362 | """ 363 | Arguments 364 | --------- 365 | index : int 366 | Token index 367 | 368 | Returns 369 | ------- 370 | token : tuple 371 | Corresponding index formed such as (subword, 'L') or (subword, 'R') 372 | For example, ('이것', 'L') or ('은', 'R'). 373 | If it is unknown, it returns None 374 | """ 375 | return self.index2vocab[index] if (0 <= index < len(self.index2vocab)) else None 376 | 377 | def _construct_word_graph(self, docs): 378 | def normalize(graph): 379 | graph_ = defaultdict(lambda: defaultdict(lambda: 0)) 380 | for from_, to_dict in graph.items(): 381 | sum_ = sum(to_dict.values()) 382 | for to_, w in to_dict.items(): 383 | graph_[to_][from_] = w / sum_ 384 | graph_ = {t:dict(fd) for t, fd in graph_.items()} 385 | return graph_ 386 | 387 | graph = defaultdict(lambda: defaultdict(lambda: 0)) 388 | for doc in docs: 389 | 390 | tokens = doc.split() 391 | 392 | if not tokens: 393 | continue 394 | 395 | links = [] 396 | for token in tokens: 397 | links += self._intra_link(token) 398 | 399 | if len(tokens) > 1: 400 | tokens = [tokens[-1]] + tokens + [tokens[0]] 401 | links += self._inter_link(tokens) 402 | 403 | links = self._check_token(links) 404 | if not links: 405 | continue 406 | 407 | links = self._encode_token(links) 408 | for l_node, r_node in links: 409 | graph[l_node][r_node] += 1 410 | graph[r_node][l_node] += 1 411 | 412 | # reverse for inbound graph. but it normalized with sum of outbound weight 413 | graph = normalize(graph) 414 | return graph 415 | 416 | def _intra_link(self, token): 417 | links = [] 418 | len_token = len(token) 419 | for e in range(1, min(len_token, 10)): 420 | if (len_token - e) > self.max_length: 421 | continue 422 | links.append( ((token[:e], 'L'), (token[e:], 'R')) ) 423 | return links 424 | 425 | def _inter_link(self, tokens): 426 | def rsub_to_token(t_left, t_curr): 427 | return [((t_left[-b:], 'R'), (t_curr, 'L')) for b in range(1, min(10, len(t_left)))] 428 | def token_to_lsub(t_curr, t_rigt): 429 | return [((t_curr, 'L'), (t_rigt[:e], 'L')) for e in range(1, min(10, len(t_rigt)))] 430 | 431 | links = [] 432 | for i in range(1, len(tokens)-1): 433 | links += rsub_to_token(tokens[i-1], tokens[i]) 434 | links += token_to_lsub(tokens[i], tokens[i+1]) 435 | return links 436 | 437 | def _check_token(self, token_list): 438 | return [(token[0], token[1]) for token in token_list if (token[0] in self.vocabulary and token[1] in self.vocabulary)] 439 | 440 | def _encode_token(self, token_list): 441 | return [(self.vocabulary[token[0]],self.vocabulary[token[1]]) for token in token_list] 442 | -------------------------------------------------------------------------------- /reference/2014_JKIIE_KimETAL_KR-WordRank.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lovit/KR-WordRank/b34cadf7f44e94d6cc64ed3bc60824fd0ddcaa8a/reference/2014_JKIIE_KimETAL_KR-WordRank.pdf -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy>=1.18.4 2 | scipy>=1.4.1 3 | scikit-learn>=0.22.1 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | 5 | with open(os.path.join(os.path.dirname(__file__), 'README.md'), encoding='utf-8') as f: 6 | long_description = f.read() 7 | 8 | 9 | def get_about(): 10 | about = {} 11 | basedir = os.path.abspath(os.path.dirname(__file__)) 12 | with open(os.path.join(basedir, 'krwordrank', 'about.py')) as f: 13 | exec(f.read(), about) 14 | return about 15 | 16 | 17 | def requirements(): 18 | with open(os.path.join(os.path.dirname(__file__), 'requirements.txt'), encoding='utf-8') as f: 19 | return f.read().splitlines() 20 | 21 | 22 | about = get_about() 23 | setup( 24 | name="krwordrank", 25 | version=about['__version__'], 26 | author=about['__author__'], 27 | author_email='soy.lovit@gmail.com', 28 | url='https://github.com/lovit/KR-WordRank', 29 | description="KR-WordRank: Korean Unsupervised Word/Keyword Extractor", 30 | long_description=long_description, 31 | long_description_content_type='text/markdown', 32 | install_requires=requirements(), 33 | keywords = ['Korean word keyword extraction'], 34 | packages=find_packages() 35 | ) 36 | -------------------------------------------------------------------------------- /tests/test_krwordrank.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | import sys 4 | root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) 5 | sys.path.insert(0, root) 6 | 7 | import krwordrank 8 | from krwordrank.hangle import initialize_pattern 9 | from krwordrank.hangle import normalize 10 | from krwordrank.sentence import summarize_with_sentences 11 | from krwordrank.word import KRWordRank 12 | 13 | # pytest execution with verbose 14 | # $ pytest tests/test_krwordrank.py -s -v 15 | 16 | 17 | @pytest.fixture 18 | def test_config(): 19 | return { 20 | 'data_path': '{}/data/134963_norm.txt'.format(root) # La La Land movie comments 21 | } 22 | 23 | 24 | def test_normalize(): 25 | input_str = '한글과 alphabet 으로 이뤄진 20글자에 가까운.. 문장이에요' 26 | form = '\npassed case: {}\ninput : {}\noutput: {}' 27 | settings = [ 28 | ('Hangle', False, False, False, '한글과 으로 이뤄진 글자에 가까운 문장이에요'), 29 | ('Hangle + English', True, False, False, '한글과 alphabet 으로 이뤄진 글자에 가까운 문장이에요'), 30 | ('Hangle + English + Number', True, True, False, '한글과 alphabet 으로 이뤄진 20글자에 가까운 문장이에요'), 31 | ('Hangle + English + Number + Punctuation', True, True, True, '한글과 alphabet 으로 이뤄진 20글자에 가까운.. 문장이에요') 32 | ] 33 | for name, english, number, punctuation, expected in settings: 34 | pattern = initialize_pattern(english, number, punctuation, remains=None) 35 | output_str = normalize(input_str, pattern=pattern) 36 | assert output_str == expected 37 | message = form.format(name, input_str, output_str) 38 | print(message) 39 | 40 | 41 | def test_keyword(test_config): 42 | data_path = test_config['data_path'] 43 | with open(data_path, encoding='utf-8') as f: 44 | texts = [line.rsplit('\t')[0].strip() for line in f] 45 | 46 | wordrank_extractor = KRWordRank(min_count = 5, max_length = 10) 47 | keywords, rank, graph = wordrank_extractor.extract(texts, beta = 0.85, max_iter = 10) 48 | selected_keywords = [word for word, r in sorted(keywords.items(), key=lambda x:x[1], reverse=True)[:30]] 49 | assert selected_keywords[:5] == ['영화', '너무', '정말', '음악', '마지막'] 50 | print('\nKR-WordRank 라라랜드 영화 리뷰 30 개 키워드\n{}\n'.format(selected_keywords)) 51 | 52 | 53 | def test_keysentence(test_config): 54 | data_path = test_config['data_path'] 55 | with open(data_path, encoding='utf-8') as f: 56 | texts = [line.rsplit('\t')[0].strip() for line in f] 57 | 58 | keywords, sents = summarize_with_sentences(texts, num_keywords=100, num_keysents=10) 59 | for word in ['영화', '너무', '정말', '음악', '마지막']: 60 | assert word in keywords 61 | assert len(sents) == 10 62 | print('\nKR-WordRank key-sentence extraction 라라랜드 영화 리뷰 10 개 핵심 문장') 63 | for sent in sents: 64 | print(' - {}'.format(sent)) 65 | -------------------------------------------------------------------------------- /tutorials/figs/kr_wordrank_fig1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lovit/KR-WordRank/b34cadf7f44e94d6cc64ed3bc60824fd0ddcaa8a/tutorials/figs/kr_wordrank_fig1.png -------------------------------------------------------------------------------- /tutorials/figs/kr_wordrank_fig2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lovit/KR-WordRank/b34cadf7f44e94d6cc64ed3bc60824fd0ddcaa8a/tutorials/figs/kr_wordrank_fig2.png -------------------------------------------------------------------------------- /tutorials/krwordrank_keysentence.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "이 튜토리얼에서는 summarize 함수의 각 부분을 나눠서 직접 실행합니다. 데이터는 라라랜드 영화 리뷰를 이용합니다." 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": 1, 13 | "metadata": {}, 14 | "outputs": [ 15 | { 16 | "name": "stdout", 17 | "output_type": "stream", 18 | "text": [ 19 | "1.0.1\n" 20 | ] 21 | } 22 | ], 23 | "source": [ 24 | "import krwordrank\n", 25 | "print(krwordrank.__version__)\n", 26 | "\n", 27 | "# La La Land\n", 28 | "fname = '../data/134963_norm.txt'\n", 29 | "\n", 30 | "def get_texts_scores(fname):\n", 31 | " with open(fname, encoding='utf-8') as f:\n", 32 | " docs = [doc.lower().replace('\\n','').split('\\t') for doc in f]\n", 33 | " docs = [doc for doc in docs if len(doc) == 2]\n", 34 | " \n", 35 | " if not docs:\n", 36 | " return [], []\n", 37 | " \n", 38 | " texts, scores = zip(*docs)\n", 39 | " return list(texts), list(scores)\n", 40 | "\n", 41 | "texts, scores = get_texts_scores(fname)" 42 | ] 43 | }, 44 | { 45 | "cell_type": "markdown", 46 | "metadata": {}, 47 | "source": [ 48 | "KR-WordRank 를 이용하여 키워드를 학습합니다." 49 | ] 50 | }, 51 | { 52 | "cell_type": "code", 53 | "execution_count": 2, 54 | "metadata": {}, 55 | "outputs": [ 56 | { 57 | "name": "stdout", 58 | "output_type": "stream", 59 | "text": [ 60 | "scan vocabs ... \n", 61 | "num vocabs = 13879\n", 62 | "done = 9 Early stopped.\n" 63 | ] 64 | } 65 | ], 66 | "source": [ 67 | "from krwordrank.word import KRWordRank\n", 68 | "\n", 69 | "wordrank_extractor = KRWordRank(\n", 70 | " min_count = 5, # 단어의 최소 출현 빈도수 (그래프 생성 시)\n", 71 | " max_length = 10, # 단어의 최대 길이\n", 72 | " verbose = True\n", 73 | " )\n", 74 | "\n", 75 | "beta = 0.85 # PageRank의 decaying factor beta\n", 76 | "max_iter = 10\n", 77 | "\n", 78 | "keywords, rank, graph = wordrank_extractor.extract(texts, beta, max_iter, num_keywords=100)" 79 | ] 80 | }, 81 | { 82 | "cell_type": "markdown", 83 | "metadata": {}, 84 | "source": [ 85 | "make_vocab_score 함수는 keywords 와 stopwords 를 이용하여 MaxScoreTokenizer 가 이용하는 단어 점수를 만드는 과정입니다.\n", 86 | "\n", 87 | "MaxScoreTokenizer 는 [soynlp](https://github.com/lovit/soynlp/) 의 토크나이저 입니다." 88 | ] 89 | }, 90 | { 91 | "cell_type": "code", 92 | "execution_count": 3, 93 | "metadata": {}, 94 | "outputs": [], 95 | "source": [ 96 | "from krwordrank.sentence import make_vocab_score\n", 97 | "from krwordrank.sentence import MaxScoreTokenizer\n", 98 | "\n", 99 | "\n", 100 | "stopwords = {'영화', '관람객', '너무', '정말', '진짜'}\n", 101 | "vocab_score = make_vocab_score(keywords, stopwords, scaling=lambda x:1)\n", 102 | "tokenizer = MaxScoreTokenizer(vocab_score)" 103 | ] 104 | }, 105 | { 106 | "cell_type": "markdown", 107 | "metadata": {}, 108 | "source": [ 109 | "Key sentence 를 추출하는데 필요한 정보는 keyword 뿐이기 때문에 토크나이징이 아주 정교하게 작동하지는 않습니다. 문장에서 키워드를 단어로 추출하는 역할만 합니다." 110 | ] 111 | }, 112 | { 113 | "cell_type": "code", 114 | "execution_count": 4, 115 | "metadata": {}, 116 | "outputs": [ 117 | { 118 | "data": { 119 | "text/plain": [ 120 | "['뮤지컬', '영화라', '그런', '지', '음악', '이좋다', '그리고', '엔딩', '이정말', '먹먹하다']" 121 | ] 122 | }, 123 | "execution_count": 4, 124 | "metadata": {}, 125 | "output_type": "execute_result" 126 | } 127 | ], 128 | "source": [ 129 | "tokenizer.tokenize('뮤지컬영화라그런지 음악이좋다 그리고 엔딩이정말 먹먹하다')" 130 | ] 131 | }, 132 | { 133 | "cell_type": "markdown", 134 | "metadata": {}, 135 | "source": [ 136 | "penalty 함수를 설정하고, 이들을 keysentence 함수에 입력합니다. 여기에서의 topk 는 핵심 문장의 개수입니다." 137 | ] 138 | }, 139 | { 140 | "cell_type": "code", 141 | "execution_count": 5, 142 | "metadata": {}, 143 | "outputs": [ 144 | { 145 | "name": "stdout", 146 | "output_type": "stream", 147 | "text": [ 148 | "사랑 꿈 현실 모든걸 다시한번 생각하게 하는 영화였어요 영상미도 너무 예쁘고 주인공도 예쁘고 내용도 아름답네요ㅠㅠ 인생 영화\n", 149 | "생각보다 굉장히 재미있는 뻔한 결말도 아니고 아름다운 음악과 현실적인 스토리구성 모두에게 와닿을법한 울림들이 차 좋았어요 추천\n", 150 | "남자친구랑 봤는데 진짜 다시 보고싶음 ㅠㅠㅠ너무 좋았어요 재즈좋아하고 뮤지컬같은거 좋아하는사람들한텐 취저영화\n", 151 | "노래도 좋고 영상미도 좋고 그리고 배우들 연기까지 정말 좋았어요 개인적으로 뮤지컬 형식 영화를 안좋아하는 편인데 재밌게 봤습니다\n", 152 | "영화같은 사랑 현실적인 결말 마지막 장면처럼 모든 것이 원하는 대로 슬픈 일 하나없이 흘러갈 수는 없는 것이 인생\n", 153 | "영상 음악 연출 연기 모든게 만점입니다 너무 현실적이라 슬프고 눈물이 나네요 라이언 고슬링이 이렇게 연기를 잘 하는 배우인지 처음 알았네요\n", 154 | "음악도 좋고 미아와 세바스티안의 아름다운 사랑과 예술에 대한 열정이 감동적이었습니다 재즈음악을 사랑하고 뮤지컬을 좋아하는 사람들에게 강추합니다\n", 155 | "음악과 영상미 모두좋았습니다 특히 마지막 10분은 가히압권이였습니다 이런좋은영화 많이보았으면좋겠네요 ㅎㅎ\n", 156 | "처음 써보는 영화에대한 평점 음악부터 연기 배경 그리고 색감 모든게 마음에 들었으며 나의 인생영화가된 영화\n", 157 | "보는내내 마음이 따뜻해지는 영화네요 노래도 좋고 좋아하는 사람과 함께 봐서 더 좋았던것 같아요 연인과 함께 보는것 추천합니다\n" 158 | ] 159 | } 160 | ], 161 | "source": [ 162 | "from krwordrank.sentence import keysentence\n", 163 | "\n", 164 | "penalty = lambda x: 0 if 25 <= len(x) <= 80 else 1\n", 165 | "\n", 166 | "sents = keysentence(\n", 167 | " vocab_score, texts, tokenizer.tokenize,\n", 168 | " penalty=penalty,\n", 169 | " diversity=0.3,\n", 170 | " topk=10\n", 171 | ")\n", 172 | "\n", 173 | "for sent in sents:\n", 174 | " print(sent)" 175 | ] 176 | }, 177 | { 178 | "cell_type": "markdown", 179 | "metadata": {}, 180 | "source": [ 181 | "위의 예시에서 diversity 를 키우면 핵심 문장 점수가 높은 문장과 cosine distance 가 diversity 보다 작고 점수가 낮은 문장은 선택되지 않습니다." 182 | ] 183 | }, 184 | { 185 | "cell_type": "code", 186 | "execution_count": 6, 187 | "metadata": {}, 188 | "outputs": [ 189 | { 190 | "name": "stdout", 191 | "output_type": "stream", 192 | "text": [ 193 | "사랑 꿈 현실 모든걸 다시한번 생각하게 하는 영화였어요 영상미도 너무 예쁘고 주인공도 예쁘고 내용도 아름답네요ㅠㅠ 인생 영화\n", 194 | "생각보다 굉장히 재미있는 뻔한 결말도 아니고 아름다운 음악과 현실적인 스토리구성 모두에게 와닿을법한 울림들이 차 좋았어요 추천\n", 195 | "남자친구랑 봤는데 진짜 다시 보고싶음 ㅠㅠㅠ너무 좋았어요 재즈좋아하고 뮤지컬같은거 좋아하는사람들한텐 취저영화\n", 196 | "음악과 영상미 모두좋았습니다 특히 마지막 10분은 가히압권이였습니다 이런좋은영화 많이보았으면좋겠네요 ㅎㅎ\n", 197 | "처음 써보는 영화에대한 평점 음악부터 연기 배경 그리고 색감 모든게 마음에 들었으며 나의 인생영화가된 영화\n", 198 | "보는 내내 두근두근 어느 순간도 눈을 뗄수 없는 환상적인 영상과 음악 현실성 높은 스토리에 배우들의 멋진 연기까지 행복한 영화였어요\n", 199 | "마지막 장면에서 라이언고슬링의 피아노 연주와 엠마스톤의 눈빛연기 그리고 두 사람이 함께 했다면 어땠을까 하는 상상씬에서의 연출이 인상적이었다\n", 200 | "인생영화 노래 연기 내용 연출이 다 엄청났다 ㅠㅠ 꿈을 위해 노력하고있는 사람에게 도움이 많이 될것같다\n", 201 | "정말 여자들이 좋아할 영화에요 영상이나 ost가 정말 예술이에요 배우들의 노래도 하나하나 다 좋았어요 마지막에 스토리가 좀 아쉽긴 하지만\n", 202 | "감동과 여운이 남는 영화네요 배우들 연기는 물론 음악과 배경까지 너무 좋아요 최근에 본 영화 중에 가장 좋았습니다 추천이요\n" 203 | ] 204 | } 205 | ], 206 | "source": [ 207 | "sents = keysentence(\n", 208 | " vocab_score, texts, tokenizer.tokenize,\n", 209 | " penalty=penalty,\n", 210 | " diversity=0.7,\n", 211 | " topk=10\n", 212 | ")\n", 213 | "\n", 214 | "for sent in sents:\n", 215 | " print(sent)" 216 | ] 217 | }, 218 | { 219 | "cell_type": "markdown", 220 | "metadata": {}, 221 | "source": [ 222 | "summarize 함수는 위 과정을 한 번에 실행합니다. topk 가 키워드와 문장에 모두 적용되어야 하기 때문에 num_keywords 와 num_sents 로 설정합니다." 223 | ] 224 | }, 225 | { 226 | "cell_type": "code", 227 | "execution_count": 7, 228 | "metadata": {}, 229 | "outputs": [ 230 | { 231 | "name": "stdout", 232 | "output_type": "stream", 233 | "text": [ 234 | "사랑 꿈 현실 모든걸 다시한번 생각하게 하는 영화였어요 영상미도 너무 예쁘고 주인공도 예쁘고 내용도 아름답네요ㅠㅠ 인생 영화\n", 235 | "생각보다 굉장히 재미있는 뻔한 결말도 아니고 아름다운 음악과 현실적인 스토리구성 모두에게 와닿을법한 울림들이 차 좋았어요 추천\n", 236 | "남자친구랑 봤는데 진짜 다시 보고싶음 ㅠㅠㅠ너무 좋았어요 재즈좋아하고 뮤지컬같은거 좋아하는사람들한텐 취저영화\n", 237 | "인생영화 노래 연기 내용 연출이 다 엄청났다 ㅠㅠ 꿈을 위해 노력하고있는 사람에게 도움이 많이 될것같다\n", 238 | "음악과 영상미 모두좋았습니다 특히 마지막 10분은 가히압권이였습니다 이런좋은영화 많이보았으면좋겠네요 ㅎㅎ\n", 239 | "처음 써보는 영화에대한 평점 음악부터 연기 배경 그리고 색감 모든게 마음에 들었으며 나의 인생영화가된 영화\n", 240 | "마지막 회상신에서 눈물이 왈칵 쏟아질뻔했다 올해중 최고의 영화를 본거 같다음악이며 배우들이며 영상이며 다시 또 보고싶은 그런 영화이다\n", 241 | "보는 내내 두근두근 어느 순간도 눈을 뗄수 없는 환상적인 영상과 음악 현실성 높은 스토리에 배우들의 멋진 연기까지 행복한 영화였어요\n", 242 | "마지막 장면에서 라이언고슬링의 피아노 연주와 엠마스톤의 눈빛연기 그리고 두 사람이 함께 했다면 어땠을까 하는 상상씬에서의 연출이 인상적이었다\n", 243 | "정말 여자들이 좋아할 영화에요 영상이나 ost가 정말 예술이에요 배우들의 노래도 하나하나 다 좋았어요 마지막에 스토리가 좀 아쉽긴 하지만\n" 244 | ] 245 | } 246 | ], 247 | "source": [ 248 | "from krwordrank.sentence import summarize_with_sentences\n", 249 | "\n", 250 | "\n", 251 | "penalty = lambda x:0 if (25 <= len(x) <= 80) else 1\n", 252 | "stopwords = {'영화', '관람객', '너무', '정말', '진짜'}\n", 253 | "\n", 254 | "keywords, sents = summarize_with_sentences(\n", 255 | " texts,\n", 256 | " penalty=penalty,\n", 257 | " stopwords = stopwords,\n", 258 | " diversity=0.7,\n", 259 | " num_keywords=100,\n", 260 | " num_keysents=10,\n", 261 | " scaling=lambda x:1,\n", 262 | " verbose=False,\n", 263 | ")\n", 264 | "\n", 265 | "for sent in sents:\n", 266 | " print(sent)" 267 | ] 268 | }, 269 | { 270 | "cell_type": "code", 271 | "execution_count": null, 272 | "metadata": {}, 273 | "outputs": [], 274 | "source": [] 275 | } 276 | ], 277 | "metadata": { 278 | "kernelspec": { 279 | "display_name": "Python 3", 280 | "language": "python", 281 | "name": "python3" 282 | }, 283 | "language_info": { 284 | "codemirror_mode": { 285 | "name": "ipython", 286 | "version": 3 287 | }, 288 | "file_extension": ".py", 289 | "mimetype": "text/x-python", 290 | "name": "python", 291 | "nbconvert_exporter": "python", 292 | "pygments_lexer": "ipython3", 293 | "version": "3.7.1" 294 | } 295 | }, 296 | "nbformat": 4, 297 | "nbformat_minor": 2 298 | } 299 | -------------------------------------------------------------------------------- /tutorials/krwordrank_word_and_keyword_extraction.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "## KRWordRank: method for word / keyword extraction\n", 8 | "\n", 9 | "KRWordRank는 Kim et al.(2014)^[1]의 논문을 바탕으로 한 비지도학습기반 단어 추출 기법으로, 데이터기반으로 주요단어 (키워드)를 추출하는 알고리즘이다. 하나의 도메인에 대한 문서들을 바탕으로 명사/형용사/동사/부사 (L set) 중에서 빈도수가 높거나, 주요 단어들과 함께 등장하는 단어를 키워드로 추출한다. KRWordRank는 이름에서 나타나는바와 같이 단어 후보 (subtokens)을 이용하여 word-graph를 생성한 뒤, PageRank의 랭킹 학습 방식을 이용하여 word-graph의 hub subtokens을 추출한다. \n", 10 | "\n", 11 | "KRWordRank는 다음의 가정을 기반으로 단어를 추출한다. **단어 주변에는 단어가 등장하며, 올바른 단어는 주위의 많은 단어들과 연결되어 있다. 그렇기 때문에 단어는 주위 단어들에 의하여 단어 점수가 보강(reinforced)된다.**\n", 12 | "\n", 13 | "\n", 14 | "![kr_wordrank_structure](figs/kr_wordrank_fig1.png)\n", 15 | "\n", 16 | "\n", 17 | "한국어는 의미를 지니는 단어 집합과 문법 기능을 하는 복합형태소 집합으로 나뉘어지며, [문법/명사] + [을/조사]와 같이 어절의 왼쪽에 의미를 지니는 단어인 명사/형용사/동사가 위치한다. 부사는 그 자체로 한 어절을 이룬다. 그렇기 때문에 KRWordRank는 의미있는 단어로서 어절 자체나 어절의 왼쪽에 등장하는 L set을 추출한다. 또한 한국어는 한 글자에 지나치게 많은 의미가 담겨져 있어 해석이 모호하기 때문에 1음절 단어는 추출되는 단어에서 제외한다. 실제로 subtokens으로 이뤄진 word-graph에서 1음절 단어들은 매우 높은 랭킹을 지닌다. KRWordRank는 아래 그림과 같이 subtokens을 어절의 위치에 따라 L/R tags를 부여하여 word-graph를 만든 뒤, 랭킹을 계산한다. \n", 18 | "\n", 19 | "![kr_wordrank_structure](figs/kr_wordrank_fig2.png)\n", 20 | "\n", 21 | "논문에서 기술되지 않은 후처리(post-processing)가 추가되었다. 영화리뷰의 경우, '영화', '영화가', '영화를' 와 같이 \"단어 + R set\"이 함께 키워드로 추출된다. 이는 KRWordRank가 주요 L set 혹은 어절을 추출하기 때문이며, '영화', '영화가' 주변 모두 올바른 단어가 위치하기 때문이다. 그렇기 때문에 '영화'라는 단어가 '영화가', '영화를' 등보다 높은 랭킹을 지녔다면, '영화' + R set는 L + R 복합어라 판단하여 제외하였다. \n", 22 | "\n", 23 | " keywords = self._select_keywords(lset, rset)\n", 24 | "\n", 25 | "두번째 후처리로, '영화', '음악', '영화음악'이 키워드로 추출되었고, '영화', '음악'이 모두 '영화음악'보다 랭킹이 높을 경우, '영화음악'은 합성어로 판단하여 이를 제거하였다. \n", 26 | "\n", 27 | " keywords = self._filter_compounds(keywords)\n", 28 | "\n", 29 | "마지막 후처리로, '스토리'가 상위 랭킹이 될 경우, 한 글자가 랭킹이 높아서 '스토' 역시 키워드로 추출될 수 있다. '스토리'가 상위 랭킹이 된다면 '스토'와 같은 substring은 키워드에서 제거하였다. \n", 30 | "\n", 31 | " keywords = self._filter_subtokens(keywords)\n", 32 | "\n", 33 | "사용법은 아래의 예제 코드와 같다. \n", 34 | "\n", 35 | "[1] Kim, H. J., Cho, S., & Kang, P. (2014). KR-WordRank: An Unsupervised Korean Word Extraction Method Based on WordRank. Journal of Korean Institute of Industrial Engineers, 40(1), 18-33." 36 | ] 37 | }, 38 | { 39 | "cell_type": "code", 40 | "execution_count": 1, 41 | "metadata": {}, 42 | "outputs": [ 43 | { 44 | "name": "stdout", 45 | "output_type": "stream", 46 | "text": [ 47 | "시사회에서 보고왔습니다동화와 재즈뮤지컬의 만남! 지루하지않고 재밌습니다\t9\n", 48 | "사랑과 꿈, 그 흐름의 아름다움을 음악과 영상으로 최대한 담아놓았다. 배우들 연기는 두말할것없고\t10\n", 49 | "지금껏 영화 평가 해본 적이 없는데 진짜..최고네요! 색감. 스토리.음악.연기 모두ㅜㅜ최고입니다!!!!\t10\n", 50 | "방금 시사회 보고 왔어요~ 배우들 매력이 눈을 뗄 수가 없게 만드네요. 한편의 그림 같은 장면들도 많고, 음악과 춤이 눈과 귀를 사로 잡았어요. 한번 더 보고 싶네요.\t10\n", 51 | "초반부터 끝까지 재미있게 잘보다가 결말에서 고국마 왕창먹음...힐링 받는 느낌들다가 막판에 기분 잡쳤습니다. 마치 감독이 하고싶은 말은 \"너희들이 원하는 결말은 이거지? 하지만 현실은 이거다!!\" 라고 말하고 싶었나보군요\t1\n" 52 | ] 53 | } 54 | ], 55 | "source": [ 56 | "def get_texts_scores(fname):\n", 57 | " # 튜토리얼에서 이용하는 `fname` 파일은 영화평과 평점이 \\t 으로 구분된 two column tsv 파일입니다.\n", 58 | " # 예시는 이 cell 의 output 을 참고하세요.\n", 59 | " with open(fname, encoding='utf-8') as f:\n", 60 | " docs = [doc.lower().replace('\\n','').split('\\t') for doc in f]\n", 61 | " docs = [doc for doc in docs if len(doc) == 2]\n", 62 | "\n", 63 | " if not docs:\n", 64 | " return [], []\n", 65 | "\n", 66 | " texts, scores = zip(*docs)\n", 67 | " return list(texts), list(scores)\n", 68 | "\n", 69 | "# La La Land\n", 70 | "fname = '../data/134963.txt'\n", 71 | "texts, scores = get_texts_scores(fname)\n", 72 | "\n", 73 | "with open(fname, encoding=\"utf-8\") as f:\n", 74 | " for _ in range(5):\n", 75 | " print(next(f).strip())" 76 | ] 77 | }, 78 | { 79 | "cell_type": "code", 80 | "execution_count": 2, 81 | "metadata": {}, 82 | "outputs": [ 83 | { 84 | "name": "stdout", 85 | "output_type": "stream", 86 | "text": [ 87 | "1.0.1\n" 88 | ] 89 | } 90 | ], 91 | "source": [ 92 | "import sys\n", 93 | "sys.path.append('../')\n", 94 | "from krwordrank.word import KRWordRank\n", 95 | "from krwordrank.hangle import normalize\n", 96 | "import krwordrank\n", 97 | "print(krwordrank.__version__)" 98 | ] 99 | }, 100 | { 101 | "cell_type": "markdown", 102 | "metadata": {}, 103 | "source": [ 104 | "단어 추출에 영어/숫자를 포함할 예정이라면 normalize함수를 이용하여 텍스트를 normalize할 것" 105 | ] 106 | }, 107 | { 108 | "cell_type": "code", 109 | "execution_count": 3, 110 | "metadata": {}, 111 | "outputs": [], 112 | "source": [ 113 | "with open('../data/134963_norm.txt', 'w', encoding='utf-8') as f:\n", 114 | " for text, score in zip(texts, scores):\n", 115 | " text = normalize(text, english=True, number=True)\n", 116 | " f.write('%s\\t%s\\n' % (text, str(score)))" 117 | ] 118 | }, 119 | { 120 | "cell_type": "code", 121 | "execution_count": 4, 122 | "metadata": {}, 123 | "outputs": [], 124 | "source": [ 125 | "# La La Land\n", 126 | "fname = '../data/134963_norm.txt'\n", 127 | "texts, scores = get_texts_scores(fname)" 128 | ] 129 | }, 130 | { 131 | "cell_type": "code", 132 | "execution_count": 5, 133 | "metadata": {}, 134 | "outputs": [ 135 | { 136 | "name": "stdout", 137 | "output_type": "stream", 138 | "text": [ 139 | "scan vocabs ... \n", 140 | "num vocabs = 13822\n", 141 | "done = 9 Early stopped.\n" 142 | ] 143 | } 144 | ], 145 | "source": [ 146 | "wordrank_extractor = KRWordRank(\n", 147 | " min_count = 5, # 단어의 최소 출현 빈도수 (그래프 생성 시)\n", 148 | " max_length = 10, # 단어의 최대 길이\n", 149 | " verbose = True\n", 150 | " )\n", 151 | "\n", 152 | "beta = 0.85 # PageRank의 decaying factor beta\n", 153 | "max_iter = 10\n", 154 | "\n", 155 | "keywords, rank, graph = wordrank_extractor.extract(texts, beta, max_iter)" 156 | ] 157 | }, 158 | { 159 | "cell_type": "markdown", 160 | "metadata": {}, 161 | "source": [ 162 | "위와 같이 vocabulary를 미리 설정하거나 decaying factor를 단어별로 다르게 (bias) 할당할 수 있으며, 모든 단어의 랭킹의 총 합은 vocabulary size와 같음. 즉 default decaying factor는 1.0" 163 | ] 164 | }, 165 | { 166 | "cell_type": "code", 167 | "execution_count": 6, 168 | "metadata": {}, 169 | "outputs": [ 170 | { 171 | "name": "stdout", 172 | "output_type": "stream", 173 | "text": [ 174 | " 영화:\t201.9608\n", 175 | " 너무:\t81.7410\n", 176 | " 정말:\t40.8016\n", 177 | " 음악:\t40.3295\n", 178 | " 마지막:\t38.9302\n", 179 | " 뮤지컬:\t23.1365\n", 180 | " 최고:\t22.0345\n", 181 | " 사랑:\t20.5910\n", 182 | " 영상:\t20.4099\n", 183 | " 아름:\t20.3295\n", 184 | " 꿈을:\t20.3000\n", 185 | " 여운이:\t19.4569\n", 186 | " 진짜:\t19.2820\n", 187 | " 노래:\t18.7877\n", 188 | " 보고:\t18.4819\n", 189 | " 좋았:\t17.6783\n", 190 | " 그냥:\t16.6981\n", 191 | " 스토리:\t16.2643\n", 192 | " 좋은:\t15.6323\n", 193 | " 인생:\t15.4943\n", 194 | " 현실:\t15.1858\n", 195 | " 생각:\t14.8838\n", 196 | " 지루:\t13.7707\n", 197 | " 감동:\t13.7404\n", 198 | " 다시:\t13.5948\n", 199 | " 보는:\t12.4019\n", 200 | " 재밌:\t11.9944\n", 201 | " 좋아:\t11.9582\n", 202 | " 재미:\t11.4880\n", 203 | " 좋고:\t11.3686\n" 204 | ] 205 | } 206 | ], 207 | "source": [ 208 | "for word, r in sorted(keywords.items(), key=lambda x:x[1], reverse=True)[:30]:\n", 209 | " print('%8s:\\t%.4f' % (word, r))" 210 | ] 211 | }, 212 | { 213 | "cell_type": "markdown", 214 | "metadata": { 215 | "collapsed": true 216 | }, 217 | "source": [ 218 | "세 가지 영화의 키워드를 비교해보겠습니다. '라라랜드 (134963.txt)', '신세계 (91031.txt)', '엑스맨 (99714.txt)'에 대하여 동일한 방식으로 normalize를 한 뒤, 상위 100개의 키워드들을 비교해보겠습니다." 219 | ] 220 | }, 221 | { 222 | "cell_type": "code", 223 | "execution_count": 7, 224 | "metadata": {}, 225 | "outputs": [], 226 | "source": [ 227 | "fnames = ['../data/91031.txt',\n", 228 | " '../data/99714.txt']\n", 229 | "\n", 230 | "for fname in fnames:\n", 231 | " texts, scores = get_texts_scores(fname)\n", 232 | " with open(fname.replace('.txt', '_norm.txt'), 'w', encoding='utf-8') as f:\n", 233 | " for text, score in zip(texts, scores):\n", 234 | " text = normalize(text, english=True, number=True)\n", 235 | " f.write('%s\\t%s\\n' % (text, str(score)))" 236 | ] 237 | }, 238 | { 239 | "cell_type": "code", 240 | "execution_count": 8, 241 | "metadata": {}, 242 | "outputs": [], 243 | "source": [ 244 | "top_keywords = []\n", 245 | "fnames = ['../data/134963_norm.txt',\n", 246 | " '../data/91031_norm.txt',\n", 247 | " '../data/99714_norm.txt']\n", 248 | "\n", 249 | "for fname in fnames:\n", 250 | " \n", 251 | " texts, scores = get_texts_scores(fname)\n", 252 | " \n", 253 | " wordrank_extractor = KRWordRank(\n", 254 | " min_count=5, max_length=10, verbose=False)\n", 255 | " \n", 256 | " keywords, rank, graph = wordrank_extractor.extract(\n", 257 | " texts, beta, max_iter)\n", 258 | " \n", 259 | " top_keywords.append(\n", 260 | " sorted(keywords.items(),\n", 261 | " key=lambda x:x[1],\n", 262 | " reverse=True)[:100]\n", 263 | " )" 264 | ] 265 | }, 266 | { 267 | "cell_type": "code", 268 | "execution_count": 9, 269 | "metadata": {}, 270 | "outputs": [ 271 | { 272 | "name": "stdout", 273 | "output_type": "stream", 274 | "text": [ 275 | " 영화 (201.961) -- 영화 (145.840) -- 엑스맨 (112.845)\n", 276 | " 너무 (81.741) -- 황정민 (99.563) -- 영화 (65.482)\n", 277 | " 정말 (40.802) -- 연기 (89.988) -- 정말 (42.081)\n", 278 | " 음악 (40.329) -- 정말 (74.653) -- 진짜 (40.935)\n", 279 | " 마지막 (38.930) -- 진짜 (64.803) -- 시리즈 (40.835)\n", 280 | " 뮤지컬 (23.137) -- 최고 (54.792) -- 너무 (38.929)\n", 281 | " 최고 (22.034) -- 너무 (51.963) -- 재밌 (34.045)\n", 282 | " 사랑 (20.591) -- 이정재 (46.503) -- 재미 (30.454)\n", 283 | " 영상 (20.410) -- 무간도 (36.144) -- 최고 (28.824)\n", 284 | " 아름 (20.329) -- 배우들 (34.579) -- 기대 (24.799)\n", 285 | " 꿈을 (20.300) -- 재밌 (28.948) -- 스토리 (24.206)\n", 286 | " 여운이 (19.457) -- 스토리 (26.741) -- 역시 (23.917)\n", 287 | " 진짜 (19.282) -- 한국 (26.367) -- 보고 (19.043)\n", 288 | " 노래 (18.788) -- 신세계 (24.480) -- 액션 (17.983)\n", 289 | " 보고 (18.482) -- 대박 (23.847) -- 그냥 (17.412)\n", 290 | " 좋았 (17.678) -- 최민식 (19.863) -- 브라이언 (16.782)\n", 291 | " 그냥 (16.698) -- 느와르 (19.465) -- 퍼스트 (15.316)\n", 292 | " 스토리 (16.264) -- 보고 (19.211) -- 진심 (14.544)\n", 293 | " 좋은 (15.632) -- 재미 (19.140) -- 다시 (14.475)\n", 294 | " 인생 (15.494) -- 그냥 (18.748) -- 싱어 (14.349)\n", 295 | " 현실 (15.186) -- 완전 (16.765) -- 완전 (13.027)\n", 296 | " 생각 (14.884) -- 잔인 (14.026) -- 명작 (12.585)\n", 297 | " 지루 (13.771) -- 역시 (13.526) -- 가장 (11.920)\n", 298 | " 감동 (13.740) -- 이런 (12.964) -- 지루 (11.272)\n", 299 | " 다시 (13.595) -- 특히 (12.954) -- 봤는데 (11.025)\n", 300 | " 보는 (12.402) -- 말이 (12.931) -- 제일 (10.684)\n", 301 | " 재밌 (11.994) -- 조폭 (12.852) -- 이건 (10.040)\n", 302 | " 좋아 (11.958) -- 기대 (12.770) -- 조금 (9.306)\n", 303 | " 재미 (11.488) -- 생각 (12.498) -- 울버린 (9.162)\n", 304 | " 좋고 (11.369) -- 몰입 (12.108) -- 이번 (9.148)\n", 305 | " 계속 (11.091) -- 그리고 (11.765) -- 생각 (8.950)\n", 306 | " 느낌 (11.025) -- 박성웅 (11.305) -- 퀵실버 (8.944)\n", 307 | " 조금 (10.993) -- 하지만 (11.219) -- 대박 (8.584)\n", 308 | " 처음 (10.825) -- 간만에 (11.018) -- 처음 (8.148)\n", 309 | " 결말 (10.639) -- 평점 (10.858) -- 그리고 (7.842)\n", 310 | " 연기 (10.473) -- 봤는데 (10.764) -- 느낌 (7.739)\n", 311 | " 그리고 (10.370) -- 다시 (10.510) -- 말이 (7.712)\n", 312 | " 장면 (10.323) -- 보는 (9.694) -- 내용 (7.709)\n", 313 | " 하는 (10.279) -- 10점 (9.614) -- 이렇게 (7.692)\n", 314 | " 있는 (10.161) -- 긴장 (9.245) -- 볼만 (7.550)\n", 315 | " 많이 (9.864) -- 많이 (9.101) -- 과거 (7.546)\n", 316 | " 사람 (9.566) -- 반전 (8.969) -- 그래도 (7.512)\n", 317 | " 모두 (9.186) -- 마지막 (8.850) -- 다음 (7.510)\n", 318 | " 라이언 (9.113) -- 내용 (8.754) -- 역대 (7.499)\n", 319 | " 기대 (9.094) -- 브라더 (8.699) -- 많이 (7.396)\n", 320 | " 재즈 (9.004) -- 좋았 (8.445) -- 하지만 (7.334)\n", 321 | " 남는 (8.960) -- 없는 (8.337) -- 전편 (7.313)\n", 322 | " 연출 (8.642) -- 계속 (8.256) -- 꿀잼 (7.068)\n", 323 | " 하지만 (8.508) -- 지루 (8.148) -- 근데 (7.050)\n", 324 | " 눈물이 (8.508) -- 정청 (8.102) -- 평점 (7.025)\n", 325 | " 이런 (8.426) -- 조금 (8.074) -- 작품 (6.874)\n", 326 | " 모든 (8.408) -- 봤습니다 (7.942) -- 이거 (6.868)\n", 327 | " 봤는데 (8.325) -- 이렇게 (7.847) -- 마블 (6.684)\n", 328 | " 올해 (8.108) -- 봤다 (7.686) -- 별로 (6.574)\n", 329 | " 꿈과 (7.709) -- 그래도 (7.649) -- 보는 (6.527)\n", 330 | " 같은 (7.702) -- 다른 (7.574) -- 약간 (6.425)\n", 331 | " 배우 (7.623) -- 모두 (7.487) -- 추천 (6.379)\n", 332 | " of (7.596) -- 근데 (7.338) -- 모두 (6.322)\n", 333 | " 내내 (7.495) -- 남자 (7.318) -- 이런 (6.229)\n", 334 | " 내가 (7.476) -- 처음 (7.314) -- 감독 (6.173)\n", 335 | " 엔딩 (7.423) -- 내내 (7.192) -- 보면 (6.069)\n", 336 | " 별로 (7.380) -- 하나 (7.180) -- 시간 (5.956)\n", 337 | " 대한 (7.028) -- 이건 (7.180) -- 모든 (5.917)\n", 338 | " 이렇게 (6.996) -- 강추 (7.162) -- 미래 (5.915)\n", 339 | " 중간에 (6.952) -- 연출 (7.127) -- 솔직히 (5.857)\n", 340 | " 평점 (6.939) -- 느낌 (6.845) -- 봐야 (5.804)\n", 341 | " 라라 (6.670) -- 장면 (6.817) -- 강추 (5.775)\n", 342 | " 가슴 (6.549) -- 없다 (6.715) -- 전작 (5.712)\n", 343 | " 엠마 (6.464) -- 멋있 (6.628) -- 재밋 (5.680)\n", 344 | " 내용 (6.394) -- 있는 (6.561) -- 봤어요 (5.645)\n", 345 | " 그런 (6.352) -- 솔직히 (6.471) -- 마지막 (5.612)\n", 346 | " 오랜만에 (6.301) -- 끝까지 (6.448) -- 아니 (5.533)\n", 347 | " 보면 (6.233) -- 가장 (6.385) -- 다른 (5.522)\n", 348 | " 이야기 (6.180) -- 캐릭터 (6.307) -- 클래스 (5.470)\n", 349 | " 마음 (6.167) -- 여운이 (6.305) -- 감동 (5.300)\n", 350 | " 감독 (6.145) -- 좋고 (6.277) -- 하나 (5.268)\n", 351 | " 한번 (6.143) -- 아니 (6.251) -- 좋았 (5.223)\n", 352 | " 가장 (6.138) -- 하는 (6.206) -- 보세요 (5.185)\n", 353 | " ost (6.111) -- 좋은 (6.170) -- 봤습니다 (5.174)\n", 354 | " 아니 (6.097) -- 필요 (6.165) -- 없다 (5.160)\n", 355 | " 없는 (6.078) -- 한번 (6.154) -- 내가 (5.130)\n", 356 | " 추천 (6.051) -- 내가 (6.037) -- 사람 (5.098)\n", 357 | " 10 (6.023) -- 탄탄한 (5.978) -- 좋아 (4.876)\n", 358 | " 함께 (6.021) -- 뻔한 (5.868) -- 새로운 (4.872)\n", 359 | " 슬픈 (6.008) -- 없고 (5.818) -- 이제 (4.857)\n", 360 | " 두번 (5.933) -- 다들 (5.643) -- 이해 (4.839)\n", 361 | " 특히 (5.875) -- 시나리오 (5.632) -- 화려한 (4.699)\n", 362 | " 서로 (5.862) -- 시간 (5.602) -- 나름 (4.686)\n", 363 | " 남자 (5.815) -- 대부 (5.568) -- 한번 (4.633)\n", 364 | " 색감 (5.749) -- 좋아 (5.520) -- 중에 (4.613)\n", 365 | " 행복 (5.741) -- 의리 (5.517) -- 캐릭터 (4.592)\n", 366 | " 하나 (5.639) -- 추천 (5.507) -- 아주 (4.545)\n", 367 | " 봤습니다 (5.402) -- 별로 (5.484) -- 만들 (4.505)\n", 368 | " 않은 (5.395) -- 보면 (5.473) -- 히어로 (4.500)\n", 369 | " 피아노 (5.319) -- 개인적으로 (5.462) -- 나온 (4.471)\n", 370 | " 약간 (5.263) -- 봐도 (5.396) -- 있는 (4.444)\n", 371 | " 멋진 (5.215) -- 아주 (5.365) -- 짱짱 (4.414)\n", 372 | " 그래도 (5.054) -- 작품 (5.325) -- 뭔가 (4.397)\n", 373 | " 아주 (5.053) -- 사람 (5.265) -- 실망 (4.396)\n", 374 | " 많은 (5.005) -- 그런 (5.214) -- 정리 (4.295)\n" 375 | ] 376 | } 377 | ], 378 | "source": [ 379 | "movie_names = ['라라랜드', '신세계', '엑스맨']\n", 380 | "for k in range(100):\n", 381 | " \n", 382 | " message = ' -- '.join(\n", 383 | " ['%8s (%.3f)' % (top_keywords[i][k][0],top_keywords[i][k][1])\n", 384 | " for i in range(3)])\n", 385 | " \n", 386 | " print(message)\n", 387 | " " 388 | ] 389 | }, 390 | { 391 | "cell_type": "markdown", 392 | "metadata": {}, 393 | "source": [ 394 | "셋 모두 영화이기 때문에 공통된 키워드가 많습니다. top 100에서 중복되는 키워드들을 제거하고 차이가 있는 키워드만 추출해서 살펴보겠습니다. " 395 | ] 396 | }, 397 | { 398 | "cell_type": "code", 399 | "execution_count": 10, 400 | "metadata": {}, 401 | "outputs": [ 402 | { 403 | "data": { 404 | "text/plain": [ 405 | "43" 406 | ] 407 | }, 408 | "execution_count": 10, 409 | "metadata": {}, 410 | "output_type": "execute_result" 411 | } 412 | ], 413 | "source": [ 414 | "keyword_counter = {}\n", 415 | "for keywords in top_keywords:\n", 416 | " words, ranks = zip(*keywords)\n", 417 | " for word in words:\n", 418 | " keyword_counter[word] = keyword_counter.get(word, 0) + 1\n", 419 | "\n", 420 | "common_keywords = {word for word, count in keyword_counter.items() if count == 3}\n", 421 | "len(common_keywords)" 422 | ] 423 | }, 424 | { 425 | "cell_type": "markdown", 426 | "metadata": {}, 427 | "source": [ 428 | "세 영화 모두에 등장하는 키워드는 총 43개가 있으며, '스토리', '많이', '진짜' 같은 단어들입니다. 이런 단어를 제외한 selected_top_keywords 리스트를 만든 다음 출력을 해보겠습니다. " 429 | ] 430 | }, 431 | { 432 | "cell_type": "code", 433 | "execution_count": 11, 434 | "metadata": {}, 435 | "outputs": [ 436 | { 437 | "data": { 438 | "text/plain": [ 439 | "\"{'내용', '가장', '이런', '보는', '너무', '좋았', '느낌', '영화', '좋아', '생각', '하나', '스토리', '평점', '내가', '조금', '추천', '그래도', '처음', '있는', '최고', '정말', '사람', '그리고', '봤는데', '재밌', '보면', '이렇게', '하지만', '별로', '모두', '재미', '지루', '보고', '봤습니다', '한번', '진짜', '다시', '기대', '아주', '아니', '마지막', '그냥', '많이'}\"" 440 | ] 441 | }, 442 | "execution_count": 11, 443 | "metadata": {}, 444 | "output_type": "execute_result" 445 | } 446 | ], 447 | "source": [ 448 | "str(common_keywords)" 449 | ] 450 | }, 451 | { 452 | "cell_type": "code", 453 | "execution_count": 12, 454 | "metadata": {}, 455 | "outputs": [], 456 | "source": [ 457 | "selected_top_keywords = []\n", 458 | "for keywords in top_keywords:\n", 459 | " selected_keywords = []\n", 460 | " for word, r in keywords:\n", 461 | " if word in common_keywords:\n", 462 | " continue\n", 463 | " selected_keywords.append((word, r))\n", 464 | " selected_top_keywords.append(selected_keywords)" 465 | ] 466 | }, 467 | { 468 | "cell_type": "code", 469 | "execution_count": 13, 470 | "metadata": {}, 471 | "outputs": [], 472 | "source": [ 473 | "def get_from_list(l, i, default=('', 0)):\n", 474 | " if len(l) <= i:\n", 475 | " return default\n", 476 | " else:\n", 477 | " return l[i]" 478 | ] 479 | }, 480 | { 481 | "cell_type": "markdown", 482 | "metadata": {}, 483 | "source": [ 484 | "라라랜드는 [음악, 사랑, 뮤지컬, 꿈]과 같은 단어들이 나오며, 신세계에서는 [황정민, 이정재, 최민식]과 같은 배우들의 이름과, 홍콩영화 무간도와 주제가 비슷하기에 '무간도'라는 단어, 그리고 ['조폭', '느와르', 잔인'] 같은 영화 분위기와 관련된 내용들이 나옵니다. 또한 '반전'이란 단어에서 반전이 있는 영화라는 것도 알 수 있겠네요. 그에 비하여 엑스맨에서는 캐릭터 이름인 ['울버린', '퀵실버'] 같은 단어들도 나옵니다. ['꿀잼', '마블']과 같은 단어로부터 마블 코믹스의 오락 영화라는 것도 알 수 있습니다. " 485 | ] 486 | }, 487 | { 488 | "cell_type": "code", 489 | "execution_count": 14, 490 | "metadata": {}, 491 | "outputs": [ 492 | { 493 | "name": "stdout", 494 | "output_type": "stream", 495 | "text": [ 496 | " 음악 (40.329) -- 황정민 (99.563) -- 엑스맨 (112.845)\n", 497 | " 뮤지컬 (23.137) -- 연기 (89.988) -- 시리즈 (40.835)\n", 498 | " 사랑 (20.591) -- 이정재 (46.503) -- 역시 (23.917)\n", 499 | " 영상 (20.410) -- 무간도 (36.144) -- 액션 (17.983)\n", 500 | " 아름 (20.329) -- 배우들 (34.579) -- 브라이언 (16.782)\n", 501 | " 꿈을 (20.300) -- 한국 (26.367) -- 퍼스트 (15.316)\n", 502 | " 여운이 (19.457) -- 신세계 (24.480) -- 진심 (14.544)\n", 503 | " 노래 (18.788) -- 대박 (23.847) -- 싱어 (14.349)\n", 504 | " 좋은 (15.632) -- 최민식 (19.863) -- 완전 (13.027)\n", 505 | " 인생 (15.494) -- 느와르 (19.465) -- 명작 (12.585)\n", 506 | " 현실 (15.186) -- 완전 (16.765) -- 제일 (10.684)\n", 507 | " 감동 (13.740) -- 잔인 (14.026) -- 이건 (10.040)\n", 508 | " 좋고 (11.369) -- 역시 (13.526) -- 울버린 (9.162)\n", 509 | " 계속 (11.091) -- 특히 (12.954) -- 이번 (9.148)\n", 510 | " 결말 (10.639) -- 말이 (12.931) -- 퀵실버 (8.944)\n", 511 | " 연기 (10.473) -- 조폭 (12.852) -- 대박 (8.584)\n", 512 | " 장면 (10.323) -- 몰입 (12.108) -- 말이 (7.712)\n", 513 | " 하는 (10.279) -- 박성웅 (11.305) -- 볼만 (7.550)\n", 514 | " 라이언 (9.113) -- 간만에 (11.018) -- 과거 (7.546)\n", 515 | " 재즈 (9.004) -- 10점 (9.614) -- 다음 (7.510)\n", 516 | " 남는 (8.960) -- 긴장 (9.245) -- 역대 (7.499)\n", 517 | " 연출 (8.642) -- 반전 (8.969) -- 전편 (7.313)\n", 518 | " 눈물이 (8.508) -- 브라더 (8.699) -- 꿀잼 (7.068)\n", 519 | " 모든 (8.408) -- 없는 (8.337) -- 근데 (7.050)\n", 520 | " 올해 (8.108) -- 계속 (8.256) -- 작품 (6.874)\n", 521 | " 꿈과 (7.709) -- 정청 (8.102) -- 이거 (6.868)\n", 522 | " 같은 (7.702) -- 봤다 (7.686) -- 마블 (6.684)\n", 523 | " 배우 (7.623) -- 다른 (7.574) -- 약간 (6.425)\n", 524 | " of (7.596) -- 근데 (7.338) -- 감독 (6.173)\n", 525 | " 내내 (7.495) -- 남자 (7.318) -- 시간 (5.956)\n", 526 | " 엔딩 (7.423) -- 내내 (7.192) -- 모든 (5.917)\n", 527 | " 대한 (7.028) -- 이건 (7.180) -- 미래 (5.915)\n", 528 | " 중간에 (6.952) -- 강추 (7.162) -- 솔직히 (5.857)\n", 529 | " 라라 (6.670) -- 연출 (7.127) -- 봐야 (5.804)\n", 530 | " 가슴 (6.549) -- 장면 (6.817) -- 강추 (5.775)\n", 531 | " 엠마 (6.464) -- 없다 (6.715) -- 전작 (5.712)\n", 532 | " 그런 (6.352) -- 멋있 (6.628) -- 재밋 (5.680)\n", 533 | " 오랜만에 (6.301) -- 솔직히 (6.471) -- 봤어요 (5.645)\n", 534 | " 이야기 (6.180) -- 끝까지 (6.448) -- 다른 (5.522)\n", 535 | " 마음 (6.167) -- 캐릭터 (6.307) -- 클래스 (5.470)\n", 536 | " 감독 (6.145) -- 여운이 (6.305) -- 감동 (5.300)\n", 537 | " ost (6.111) -- 좋고 (6.277) -- 보세요 (5.185)\n", 538 | " 없는 (6.078) -- 하는 (6.206) -- 없다 (5.160)\n", 539 | " 10 (6.023) -- 좋은 (6.170) -- 새로운 (4.872)\n", 540 | " 함께 (6.021) -- 필요 (6.165) -- 이제 (4.857)\n", 541 | " 슬픈 (6.008) -- 탄탄한 (5.978) -- 이해 (4.839)\n", 542 | " 두번 (5.933) -- 뻔한 (5.868) -- 화려한 (4.699)\n", 543 | " 특히 (5.875) -- 없고 (5.818) -- 나름 (4.686)\n", 544 | " 서로 (5.862) -- 다들 (5.643) -- 중에 (4.613)\n", 545 | " 남자 (5.815) -- 시나리오 (5.632) -- 캐릭터 (4.592)\n", 546 | " 색감 (5.749) -- 시간 (5.602) -- 만들 (4.505)\n", 547 | " 행복 (5.741) -- 대부 (5.568) -- 히어로 (4.500)\n", 548 | " 않은 (5.395) -- 의리 (5.517) -- 나온 (4.471)\n", 549 | " 피아노 (5.319) -- 개인적으로 (5.462) -- 짱짱 (4.414)\n", 550 | " 약간 (5.263) -- 봐도 (5.396) -- 뭔가 (4.397)\n", 551 | " 멋진 (5.215) -- 작품 (5.325) -- 실망 (4.396)\n", 552 | " 많은 (5.005) -- 그런 (5.214) -- 정리 (4.295)\n" 553 | ] 554 | } 555 | ], 556 | "source": [ 557 | "for k in range(100 - len(common_keywords) ):\n", 558 | " \n", 559 | " message = ' -- '.join(\n", 560 | " ['%8s (%.3f)' % get_from_list(selected_top_keywords[i], k) for i in range(3)])\n", 561 | " \n", 562 | " print(message)\n", 563 | " " 564 | ] 565 | }, 566 | { 567 | "cell_type": "code", 568 | "execution_count": null, 569 | "metadata": { 570 | "collapsed": true 571 | }, 572 | "outputs": [], 573 | "source": [] 574 | } 575 | ], 576 | "metadata": { 577 | "anaconda-cloud": {}, 578 | "kernelspec": { 579 | "display_name": "Python 3", 580 | "language": "python", 581 | "name": "python3" 582 | }, 583 | "language_info": { 584 | "codemirror_mode": { 585 | "name": "ipython", 586 | "version": 3 587 | }, 588 | "file_extension": ".py", 589 | "mimetype": "text/x-python", 590 | "name": "python", 591 | "nbconvert_exporter": "python", 592 | "pygments_lexer": "ipython3", 593 | "version": "3.7.9" 594 | } 595 | }, 596 | "nbformat": 4, 597 | "nbformat_minor": 1 598 | } 599 | -------------------------------------------------------------------------------- /tutorials/lalaland_wordcloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lovit/KR-WordRank/b34cadf7f44e94d6cc64ed3bc60824fd0ddcaa8a/tutorials/lalaland_wordcloud.png --------------------------------------------------------------------------------