spacy를 이용해서 자연어처리하자.

intro

  • 저는 지금 텍스트 분석을 하고 있습니다. 이 과정에서 주어진 문장들에서 (subject, verb, object)의 형식으로 요소를 추출해내는 방법을 찾고 있습니다.
  • 물론 nltk를 써도 되는데, 사실 얘는 약간, 너무 덩치가 크고, 무거워요. 그래서 잘 안쓰게 되는 경향이 있었던 것 같아요. 물론, 제가 잘 못 써서 그런거 아니냐? 라고 말씀하신다면 할말이 없습니다 하하핫.
  • 아무튼, 그래서 좀 편하게 쓸 수 있는 자연어처리 라이브러리를 찾고 있습니다. 그 와중에 spacy를 발견했습니다.

what is spacy

Industrial-Strength Natural Language Processing IN PYTHON

  • 라고 합니다.
  • 다음 세 가지를 강점으로 표현하고 있는데요.

  • Fastest in the world

spaCy excels at large-scale information extraction tasks. It’s written from the ground up in carefully memory-managed Cython. Independent research has confirmed that spaCy is the fastest in the world. If your application needs to process entire web dumps, spaCy is the library you want to be using.

  • Get things done

spaCy is designed to help you do real work — to build real products, or gather real insights. The library respects your time, and tries to avoid wasting it. It’s easy to install, and its API is simple and productive. We like to think of spaCy as the Ruby on Rails of Natural Language Processing.

  • Deep learning

spaCy is the best way to prepare text for deep learning. It interoperates seamlessly with TensorFlow, PyTorch, scikit-learn, Gensim and the rest of Python’s awesome AI ecosystem. With spaCy, you can easily construct linguistically sophisticated statistical models for a variety of NLP problems.

  • 1) (독립적인 연구에 의하면) 세계에서 제일 빠르고, 2) 사용하기 쉬워서 빠르게 자연어처리를 할 수 있고, 3) 딥러닝 프레임웤과 호환이 잘되기 때문에, 매우 범용적이다. 라고 하네요.

install it

  • 일단 설치부터 합니다. install spacy.
  • 저는 conda로 설치하고, 영어를 분석하기 때문에 다음의 커맨드를 실행해줍니다.
conda install -c conda-forge spacy
python -m spacy download en

do it.

  • 저는 자연어처리를 전문적으로 하지는 않습니다. 단, 논문의 초록 데이터를 가져와서, 빠르게 분석할 수 있으면 좋다고 생각하구요. spacy guide의 내용대로 순서대로 진행해보려고 합니다.

basic

  • 다음처럼 제가 원하는 sentence를 그냥 제가 불러온 언어 모델에 그대로 넘겨버리면 됩니다.
    • 저는 지금 긴 텍스트를 한번에 넘겼는데요(현재는 문장 하나지만, 여러 문장을 동시에 넘겨도 상관없습니다.)
    • 또한, 텍스트를 전체그대로 넘기면, 타입은 <class 'spacy.tokens.doc.Doc'>가 되고
    • 해당 타입을 리스트로 변환하면, <class 'spacy.tokens.token.Token'>를 엘레먼트로 가지는 리스트로 변환됩니다.
import spacy 
## 다음처럼 spacy에서 내가 원하는 언어의 모델을 가져오고, 
nlp = spacy.load('en_core_web_sm')
## 다음처럼 문장을 nlp에 넘기기만 하면 끝납니다. 
doc = nlp('Apple is looking at buyin at U.K startup for $1 billion.')
print(type(doc)) ## 타입은, Doc고, 
print(doc)## 그냥 출력하면, 원래 문장이 그대로 나오고, 
print(list(doc))## 리스트로 변형하면, tokenize한 결과가 나오고 
print(type(doc[0]))## 리스트의 가장 앞에 있는 값은 Token이라는 타입이죠. 
<class 'spacy.tokens.doc.Doc'>
Apple is looking at buyin at U.K startup for $1 billion.
[Apple, is, looking, at, buyin, at, U.K, startup, for, $, 1, billion, .]
<class 'spacy.tokens.token.Token'>

POS tagging

  • 자연어처리에서 token이라 함은, 쉽게 생각하면 공백에 따라서 문장을 쪼갠 것이라고 생각하시면 됩니다. 정확히는 문장을 구성하는 요소들을 쪼갠 것이죠.
  • 각 token은 해당 문장에서 어떤 역할을 가지고 있는데, 이 역할을 Part-of-Speech라고 POS라고 합니다. 이걸 잘 쪼개야, 문장을 잘 처리할 수 있으니까요.
  • 개별 토큰에서는 다음을 파악할 수 있습니다.
    • Text: The original word text.
    • Lemma: The base form of the word.(축약한 상태)
    • POS: The simple part-of-speech tag.
    • Tag: The detailed part-of-speech tag.
    • Dep: Syntactic dependency, i.e. the relation between tokens.
    • Shape: The word shape – capitalisation, punctuation, digits.
    • is alpha: Is the token an alpha character?(알파벳만 있느냐)
    • is stop: Is the token part of a stop list, i.e. the most common words of the language?
      • stop word는, 별로 필요없는 단어인가를 의미합니다. 보통, 어떤 문장에든 포함되는 is, the, a 등은 쓸모없는 문장으로 고려되는 경우가 많고, 이러한 단어를 실제로도 제외하는 것이 좋기 때문에, is stop의 경우는 해당 단어가, 쓸모없는 단어인지를, 기존에 학습된 모델을 사용하여 제외합니다.
## part of speech tagging 

temp_str = """
Text: The original word text.
Lemma: The base form of the word.(축약한 상태)
POS: The simple part-of-speech tag.
Tag: The detailed part-of-speech tag.
Dep: Syntactic dependency, i.e. the relation between tokens.
Shape: The word shape – capitalisation, punctuation, digits.
is alpha: Is the token an alpha character?
is stop: Is the token part of a stop list, i.e. the most common words of the language?
""".strip()
print(temp_str)
print("=="*40)

str_format = "{:>10}"*8
print(str_format.format(*temp_dict.keys()))
print("=="*40)

doc = nlp('Apple is looking at buying U.K. startup for $1 billion')
for token in doc:
    print(str_format.format(token.text, token.lemma_, token.pos_, token.tag_, 
                            token.dep_, token.shape_, str(token.is_alpha), str(token.is_stop)))
    
Text: The original word text.
Lemma: The base form of the word.(축약한 상태)
POS: The simple part-of-speech tag.
Tag: The detailed part-of-speech tag.
Dep: Syntactic dependency, i.e. the relation between tokens.
Shape: The word shape – capitalisation, punctuation, digits.
is alpha: Is the token an alpha character?
is stop: Is the token part of a stop list, i.e. the most common words of the language?
================================================================================
      Text     Lemma       POS       Tag       Dep     Shape  is alpha   is stop
================================================================================
     Apple     apple     PROPN       NNP     nsubj     Xxxxx      True     False
        is        be      VERB       VBZ       aux        xx      True      True
   looking      look      VERB       VBG      ROOT      xxxx      True     False
        at        at       ADP        IN      prep        xx      True      True
    buying       buy      VERB       VBG     pcomp      xxxx      True     False
      U.K.      u.k.     PROPN       NNP  compound      X.X.     False     False
   startup   startup      NOUN        NN      dobj      xxxx      True     False
       for       for       ADP        IN      prep       xxx      True      True
         $         $       SYM         $  quantmod         $     False     False
         1         1       NUM        CD  compound         d     False     False
   billion   billion       NUM        CD      pobj      xxxx      True     False

dependency parsing

  • 앞서 문장을 tokenizing하고 POS에 따라서 나누었습니다. 이후에는 각각의 token들간의 의존관계를 고려하여, 관련있는 단어들을 묶을 수 있겠죠.

noun chunking

  • 그냥, doc.noun_chunks를 하면, 알아서 dependency graph를 고려하여, noun phrase를 뽑아줍니다. 출력 값은 generator이고, 이를 리스트로 변환해서 모두 불러오면 되고, 각각은 token 클래스가 아니라, span class입니다(token의 복합어 느낌이죠)
doc = nlp("Autonomous cars shift insurance liability toward manufacturers")

## 특정 텍스트를 nlp에 넘기면 모두 해결되기는 하는데, 
## noun_chunks의 경우는 token 클래스도 아니고, Doc 클래스도 아니다. 
## Span이라는 클래스는 그냥 Doc와 비슷하다고 생각하면 된다, 일종의 복합어 개념.
noun_chunks = doc.noun_chunks
print(type(noun_chunks))
noun_chunk = list(noun_chunks)[0]
print(type(noun_chunk))
token = noun_chunk[0]
print(type(token))

print("=="*30)
print("""
Text: The original noun chunk text.
Root text: The original text of the word connecting the noun chunk to the rest of the parse.
Root dep: Dependency relation connecting the root to its head.
Root head text: The text of the root token's head.
""".strip())
print("=="*30)
str_format = "{:>25}"*4
for chunk in doc.noun_chunks:
    print(str_format.format(chunk.text, chunk.root.text, chunk.root.dep_, chunk.root.head.text))

<class 'generator'>
<class 'spacy.tokens.span.Span'>
<class 'spacy.tokens.token.Token'>
============================================================
Text: The original noun chunk text.
Root text: The original text of the word connecting the noun chunk to the rest of the parse.
Root dep: Dependency relation connecting the root to its head.
Root head text: The text of the root token's head.
============================================================
          Autonomous cars                     cars                    nsubj                    shift
      insurance liability                liability                     dobj                    shift
            manufacturers            manufacturers                     pobj                   toward
  • spacy를 사용해서 dependency tree를 돌아다닐 수 있습니다. 예를 들어 설명하면,

Autonomous cars shift insurance liability toward manufacturers

  • 위와 같은 문장이 있을 때, 가장 핵심 단어는 shift가 됩니다. 이를 root node가 되고, 해당 node와 직접 연결된 다른 노드들은 children이 되죠. 반대로 children의 부모 노드는 head로 접근할 수 있습니다.
  • 대략 다음의 방식으로 접근할 수 있구요.
## navigiting parse tree
doc = nlp("Autonomous cars shift insurance liability toward manufacturers")
for tok in doc:
    print(tok.text)
    children = list(tok.children)
    print('children:', children, 'head:', tok.head if tok.head != tok else "!this is root node")
    print("=="*16)
Autonomous
children: [] head: cars
================================
cars
children: [Autonomous] head: shift
================================
shift
children: [cars, liability, toward] head: !this is root node
================================
insurance
children: [] head: liability
================================
liability
children: [insurance] head: shift
================================
toward
children: [manufacturers] head: shift
================================
manufacturers
children: [] head: toward
================================
  • 이를 간단하게 네트워크로 표현할 수도 있습니다.
import networkx as nx
import matplotlib.pyplot as plt 

nG = nx.Graph()
doc[2] ## root node

def add_n_to_g(inputG, tok):
    inputG.add_node(tok)
    children = list(tok.children)
    if children != []:
        inputG.add_nodes_from(children)
        for c in children:
            inputG.add_edges_from([(tok, c, {'dependency':c.dep_})])
            add_n_to_g(inputG, c)
add_n_to_g(nG, doc[2])
print(nG.nodes(data=True))
print("=="*20)
for e in nG.edges(data=True):
    print(f"{e[0]}, {e[1]}, ### dependency: {e[2]['dependency']}")
[(shift, {}), (cars, {}), (liability, {}), (toward, {}), (Autonomous, {}), (insurance, {}), (manufacturers, {})]
========================================
shift, cars, ### dependency: nsubj
shift, liability, ### dependency: dobj
shift, toward, ### dependency: prep
cars, Autonomous, ### dependency: amod
liability, insurance, ### dependency: compound
toward, manufacturers, ### dependency: pobj

named entity recognition(NER)

  • 해당 토큰이 각각 문장에서 어떤 역할을 하는지는 품사등으로 예측할 수 있겠는데, 의미적으로 어떤지, 예를 들면 도시인지, 사람인지, 제품인지 등을 모릅니다.
  • 이를 발견하는 것을 named entity recognition이라고 하고, spacy는 여기에 대해서도 간단하게 해결해줍니다.
doc = nlp('Apple is looking at buying U.K. startup for $1 billion')

for ent in doc.ents:
    print(ent.text, ent.label_)
  • GPE는 도시 등을 말하죠.
Apple ORG
U.K. GPE
$1 billion MONEY
  • 물론 당연하지만, 완벽하지는 않습니다. 아래를 보시면, 구글, 애플, 아마존, 애플 등에 대해서는 잘 발견해주지만, Alexa, Echo, Dot 은 모두 제품과 서비스인데 잘 안되는 것을 알 수 있죠.
doc = nlp("""But Google is starting from behind. The company made a late push
into hardware, and Apple’s Siri, available on iPhones, and Amazon’s Alexa
software, which runs on its Echo and Dot devices, have clear leads in
consumer adoption.""".replace("\n", " ").strip())

## 아래처럼 무엇이 organization이고, 무엇이 product인지, 꽤 잘 구별해주지만, 
## echo, dot 등에 대해서는 정확하지 못하다. 
for ent in doc.ents:
    print(ent.text, ent.label_)
Google ORG
Apple ORG
iPhones PRODUCT
Amazon ORG
Alexa ORG
Echo GPE
Dot ORG

sentence segmentation

  • 문장은 다음처럼 매우 간단하게 쪼갤 수 있구요
doc = nlp("This is a sentence. This is another sentence.")
for sent in doc.sents:
    print(sent.text)
This is a sentence.
This is another sentence.

similarity

  • 단어간, 문장간 유사도를 체크합니다.
  • 이 때, 이전처럼 en_core_web_sm를 쓸 경우에는 잘 안되고, 좀 더 큰걸 사용해야 합니다.
  • 아래를 사용해서 다운받으시고요.
python -m spacy download en_core_web_md
python -m spacy download en_core_web_lg
  • documentation에 있는 코드를 그대로 실행했습니다.
  • documenatation에 의하면, dog, cat의 유사도는 0.8정도가 나와야 하는데, 여기서는 그렇게 나오지 않습니다.
  • 또한, banana와 cat을 넣었을 때와, cat과 banana를 넣었을때의 결과도 다르게 나오고 있습니다.
import spacy

nlp = spacy.load('en_core_web_md')  # make sure to use larger model!
tokens = nlp(u'dog cat banana')

for token1 in tokens:
    for token2 in tokens:
        print(token1.text, token2.text, token1.similarity(token2))
dog dog 1.0
dog cat 0.0
dog banana 0.0
cat dog 0.0
cat cat 1.0
cat banana -0.0446812
banana dog -7.82874e+17
banana cat -8.24222e+17
banana banana 1.0
  • 보통 워드 벡터를 활용해서 유사도를 계산할 때는 cosine similarity를 사용합니다. 그래서 제가 직접 벡터를 사용해서 유사도를 계산해봤는데, 그래도 딱히 잘 나오는것 같지는 않아요…흠.
import spacy
from sklearn.metrics.pairwise import cosine_similarity, euclidean_distances

nlp = spacy.load('en_core_web_md')  # make sure to use larger model!
tokens = nlp(u'dog cat banana')

for token1 in tokens:
    for token2 in tokens:
        ## np.dot(token1.vector, token2.vector) / (token1.vector_norm*token2.vector_norm)
        sim = cosine_similarity(token1.vector.reshape(1, 300), 
                          token2.vector.reshape(1, 300)
                         )
        print(token1.text, token2.text, sim)
dog dog [[ -3.68934881e+19]]
dog cat [[ 2.]]
dog banana [[ 2.]]
cat dog [[ 2.]]
cat cat [[ -3.68934881e+19]]
cat banana [[ -1.08420217e-19]]
banana dog [[ 2.]]
banana cat [[ -1.08420217e-19]]
banana banana [[  1.08420217e-19]]

wrap-up

  • 우선, 간단하게 사용해본 결과로는, 진짜, 다른 자연어처리 라이브러리에 비해서 인터페이스가 아주 편합니다(nltk 쓰다가 이걸 쓰니까 암이 낫는 기분이에요). 디펜더시 트리도 빠르게 뽑을 수 있어서, 매우 좋은 것 같아요.

  • 단, 생각했던 것처럼, 모든 문장에 대해서 “Subject - Verb - Object”의 형태로 뽑아내는 것은 매우 어려운 것 같습니다.
  • 그래서 간단하게, sentence를 뽑고 noun chunk를 뽑아서, 처리하는 식으로 진행했습니다.
  • 조금 보니까, 좀 제대로 하려면, nltk를 쓰는 것이 훨씬 나을 것 같기도 하구요.
  • 또한 word-embedding을 활용해서, 두 문서간의 similarity를 계산해주긴 하지만, 값이 단 하나의 float 값으로 나옵니다. 따라서, 그냥 두 문서가 비슷하다, 비슷하지 않다, 정도만 파악할 수 있어요.
  • 오히려, sklearn의 tfidf를 사용해서 계산해주는 것이 더 좋을 것 같습니다.

reference

댓글남기기