05-02 06:32
Notice
Recent Posts
Recent Comments
관리 메뉴

Scientific Computing & Data Science

[Artificial Intelligence / Machine Learning] Naive Bayes Spam Filter Part 2. 본문

Artificial Intelligence/Machine Learning

[Artificial Intelligence / Machine Learning] Naive Bayes Spam Filter Part 2.

cinema4dr12 2016. 11. 12. 22:56

Written by Geol Choi | 


이전 글(Naive Bayes Spam Filter Part 1.)에서 Naive Bayes에 대한 이론을 다뤘습니다.


이번 글에서는 이론을 바탕으로 휴대폰의 SMS 데이터의 Spam Filter를 작성해 보도록 하겠습니다.

일반적인 데이터 분석 프로세스는,


(1)   문제 정의

(2)   데이터 획득

(3)   데이터 클린업

(4)   데이터 정규화

(5)   데이터 변형 및 가공

(6)   데이터 탐구 기반 통계

(7)   데이터 탐구 기반 시각화

(8)   예측 모델

(9)   모델 평가

(10) 결과에 대한 시각화 및 해석

(11) 솔루션 배포





인데, Machine Learning에 의한 결과 도출도 이 순서와 크게 다르지 않으며, 전체적인 순서는 다음 그림과 같습니다.






1. 데이터 획득

먼저 SMS Spam Collection 사이트를 방문하여 Spam/Ham이 분류된 메시지 파일을 다운로드합니다.


다운로드 링크는,

http://www.dt.fee.unicamp.br/~tiago/smsspamcollection/smsspamcollection.zip

입니다.


다운로드한 파일을 압축을 풀면 "SMSSpamCollection.txt" 파일이 있습니다. 이 파일을 텍스트 편집기를 이용하여 열면 다음의 내용들을 확인할 수 있습니다 (참고로 본 파일은 지속적으로 업데이트 되므로 내용이 약간 다를 수 있습니다).



ham	Go until jurong point, crazy.. Available only in bugis n great world la e buffet... Cine there got amore wat...
ham	Ok lar... Joking wif u oni...
spam	Free entry in 2 a wkly comp to win FA Cup final tkts 21st May 2005. Text FA to 87121 to receive entry question(std txt rate)T&C's apply 08452810075over18's
ham	U dun say so early hor... U c already then say...
ham	Nah I don't think he goes to usf, he lives around here though
spam	FreeMsg Hey there darling it's been 3 week's now and no word back! I'd like some fun you up for it still? Tb ok! XxX std chgs to send, £1.50 to rcv
ham	Even my brother is not like to speak with me. They treat me like aids patent.
ham	As per your request 'Melle Melle (Oru Minnaminunginte Nurungu Vettam)' has been set as your callertune for all Callers. Press *9 to copy your friends Callertune
spam	WINNER!! As a valued network customer you have been selected to receivea £900 prize reward! To claim call 09061701461. Claim code KL341. Valid 12 hours only.
spam	Had your mobile 11 months or more? U R entitled to Update to the latest colour mobiles with camera for Free! Call The Mobile Update Co FREE on 08002986030
ham	I'm gonna be home soon and i don't want to talk about this stuff anymore tonight, k? I've cried enough today.
spam	SIX chances to win CASH! From 100 to 20,000 pounds txt> CSH11 and send to 87575. Cost 150p/day, 6days, 16+ TsandCs apply Reply HL 4 info
spam	URGENT! You have won a 1 week FREE membership in our £100,000 Prize Jackpot! Txt the word: CLAIM to No: 81010 T&C www.dbuk.net LCCLTD POBOX 4403LDNW1A7RW18
ham	I've been searching for the right words to thank you for this breather. I promise i wont take your help for granted and will fulfil my promise. You have been wonderful and a blessing at all times.
...
...


2. 데이터 파일 변환

다운받은 텍스트 데이터 파일의 Message Type(Ham/Spam)과 Message 간에는 탭(Tab)으로 구분되어 있는데, 이를 CSV(Comma Separated-Values)로 변환합니다.


변환 방법은,
(1) 파일을 열어 한 줄씩 읽으면서
(2) Comma(,), 큰 따옴표("), 작은 따옴표(')를 삭제하고
(3) Type과 Message를 분류하는 탭("\t")을 공란(White-space)으로 대체하고,
(4) 분리된 첫번째 단어를 
type으로, 나머지 문자열을 message로 지정하여 (이때, 해당 단어가 "NA"일 경우 건너뜁니다)

(5) Data Frame에 저장 후
(6) 이를 CSV 파일로 저장하는 것입니다.


상기 제시한 코드는 다음과 같습니다:


MakeCsv.R

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
MakeCsv = function(inputFile, outputFile) {
  textLines <- base::readLines(inputFile, encoding = "UTF-8");
  
  df <- data.frame(matrix(ncol = 2, nrow = 1));
  base::names(df) <- c("type""message");
  
  cnt <- 0;
  
  for(i in 1:base::length(textLines)) {
    curText <- textLines[i];
    curText <- base::gsub(",""", curText);
    curText <- base::gsub("\'""", curText);
    curText <- base::gsub("\"""", curText);
    curText <- base::gsub("\t"" ", curText);
    curText <- base::strsplit(curText, " ");
    
    if(!base::is.na(curText[[1]][1])) {
      cnt <- cnt + 1;
      
      df[cnt,]$type <- curText[[1]][1];
      myMsg <- "";
      
      for(j in 2:base::length(curText[[1]])) {
        myMsg <- base::paste(myMsg, curText[[1]][j]);
      }
      
      df[cnt,]$message <- myMsg;
    }
  }
  
  utils::write.csv(x = df, file = outputFile, row.names = FALSE, quote = FALSE, fileEncoding = "UTF-8");
}
cs


만약 상기 R Script 파일과 동일한 경로에서 다음 R Script를 실행하면 CSV 파일로 변환이 가능합니다.


1
2
3
4
5
6
source('./MakeCsv.R'echo=TRUE)
 
inputFile <- "./SMSSpamCollection.txt"
outputFile <- "./SMSSpamCollection.csv"
 
MakeCsv(inputFile, outputFile)
cs


이를 이용하여 CSV로 변환된 파일은 다음과 같습니다.


SMSSpamCollection.csv

type,message
ham,Go until jurong point crazy.. Available only in bugis n great world la e buffet... Cine there got amore wat...
ham,Ok lar... Joking wif u oni...
spam,Free entry in 2 a wkly comp to win FA Cup final tkts 21st May 2005. Text FA to 87121 to receive entry question(std txt rate)T&Cs apply 08452810075over18s
ham,U dun say so early hor... U c already then say...
ham,Nah I dont think he goes to usf he lives around here though
spam,FreeMsg Hey there darling its been 3 weeks now and no word back! Id like some fun you up for it still? Tb ok! XxX std chgs to send 짙1.50 to rcv
ham,Even my brother is not like to speak with me. They treat me like aids patent.
ham,As per your request Melle Melle (Oru Minnaminunginte Nurungu Vettam) has been set as your callertune for all Callers. Press *9 to copy your friends Callertune
spam,WINNER!! As a valued network customer you have been selected to receivea 짙900 prize reward! To claim call 09061701461. Claim code KL341. Valid 12 hours only.
spam,Had your mobile 11 months or more? U R entitled to Update to the latest colour mobiles with camera for Free! Call The Mobile Update Co FREE on 08002986030
ham,Im gonna be home soon and i dont want to talk about this stuff anymore tonight k? Ive cried enough today.
spam,SIX chances to win CASH! From 100 to 20000 pounds txt> CSH11 and send to 87575. Cost 150p/day 6days 16+ TsandCs apply Reply HL 4 info
spam,URGENT! You have won a 1 week FREE membership in our 짙100000 Prize Jackpot! Txt the word: CLAIM to No: 81010 T&C www.dbuk.net LCCLTD POBOX 4403LDNW1A7RW18
ham,Ive been searching for the right words to thank you for this breather. I promise i wont take your help for granted and will fulfil my promise. You have been wonderful and a blessing at all times.
...
...


이 과정이 번거롭다면 다음 링크의 파일을 다운로드 합니다:


SMSSpamCollection.csv



CSV 파일은 Microsoft® Excel에서도 열립니다.



3. 데이터 확인

utils 패키지의 read.csv() 함수를 이용하여 CSV 데이터를 sms_raw에 로딩합니다.


sms_raw <- utils::read.csv("SMSSpamCollection.csv", stringsAsFactors = FALSE, fileEncoding = "UTF-8")


[참고!] base, utils 등 R의 기본 패키지는 R 실행 시 자동 로딩되는 패키지이므로 굳이 utils:: 를 붙일 필요는 없지만, 중복되는 함수명을 피하기 위하여 namespace를 표시하는 습관도 나쁘지 않으리라 생각됩니다.


base::summary() 함수로 로딩된 데이터의 대략적인 정보를 알아봅니다:


> base::summary(sms_raw)
     type             message         
 Length:5550        Length:5550       
 Class :character   Class :character  
 Mode  :character   Mode  :character


데이터의 Feature로는 type과 message가 있으며, 메시지의 총 개수는 5550개입니다. Data Class는 모두 문자(character)입니다.


utils::str() 함수로도 대략적인 정보를 확인할 수 있습니다:


> utils::str(sms_raw)
'data.frame':	5550 obs. of  2 variables:
 $ type   : chr  "ham" "ham" "spam" "ham" ...
 $ message: chr  " Go until jurong point crazy.. Available only in bugis n great world la e buffet... Cine there got amore wat..." " Ok lar... Joking wif u oni..." " Free entry in 2 a wkly comp to win FA Cup final tkts 21st May 2005. Text FA to 87121 to receive entry question(std txt rate)T&"| __truncated__ " U dun say so early hor... U c already then say..." ...


현재 sms_raw$type은 아래에서 확인할 수 있는 것처럼 문자열로 이루어져 있으므로,


> utils::str(sms_raw$type)
 chr [1:5550] "ham" "ham" "spam" "ham" "ham" "spam" "ham" "ham" "spam" "spam" ...


이를 요인(Factor)로 변환힙니다:


sms_raw$type <- base::factor(sms_raw$type)

다시 한 번 sms_raw$type을 확인해 보면,


> utils::str(sms_raw$type)
 Factor w/ 2 levels "ham","spam": 1 1 2 1 1 2 1 1 2 2 ...


base::table() 함수를 이용하면 요인 수준(Factor Level)에 따른 개수를 확인할 수 있습니다:


> base::table(sms_raw$type)

 ham spam 
4805  745 


위의 결과를 보면, 전체에서 ham이 차지하는 비율은 86.6% 정도이며, spam이 차지하는 비율은 13.4% 정도입니다.


4. 데이터 가공

데이터 가공 또는 데이터 클린업(Clean-up)은 다음의 순서로 진행합니다:

(1) Corpus 변환: 문장으로 이루어진 각 메시지들을 단어 단위로 데이터를 저장합니다.

(2) 소문자 변환: ham/spam을 분류하는데 있어 대소문자 구분은 의미가 없으므로 모두 소문자로 변환합니다.

(3) 숫자 제거: 숫자가 경우에 따라 의미있는 정보가 될 수는 있으나 ham/spam을 숫자를 기준으로 판단하기에는 무리가 있으므로 제거합니다.

(4) Stopwords 제거: 영어에서 대명사, 전치사, 조동사, 의문사, 관사 등등이다. 본 튜토리얼에서는 영어로 된 메시지를 처리하지만 한국어도 얼마든지 가능합니다. Stopwords는 사용자가 정의할 수 있습니다.

(5) 구두점 제거: 따옴표, 마침표, 쉼표 등등을 제거합니다.

(6) Stemming: 동사의 형변화에 따라 다양한 형태를 기본형으로 처리한다. 즉, "go", "went", "gone", "going" 등등은 모두 "go"로 변환합니다.

(7) 불필요한 공란 제거: 불필요한 공란을 white space라고 하는데 이를 제거합니다.

Corpus 변환

메시지들을 처리하려면 단어 단위로 저장해야 합니다. sms_raw$message를 VectorSource로 변환하고 변환된 VectorSource를 Corpus로 변환한다. Corpus는 디스크, 데이터베이스 등에 영구적으로 저장하는 PCorpus(Permanent Corpus)와 RAM 등 메모리에 일시적으로 저장하는 VCorpus(Volatile Corpus)가 있습니다.


VectorSource는 벡터의 모든 요소를 document로 해석합니다. Corpus를 처리하려면 tm 패키지(tm; Text Mining)가 설치되어 있어야 합니다:


> install.packages("tm")


다음 명령을 실행하여 Corpus 데이터를 생성합니다:


vs <- tm::VectorSource(sms_raw$message)
sms_corpus <- tm::VCorpus(vs)

sms_corpus를 살펴보도록 하겠습니다.


> tm::inspect(sms_corpus[1:5])
<<VCorpus>>
Metadata:  corpus specific: 0, document level (indexed): 0
Content:  documents: 5

[[1]]
<<PlainTextDocument>>
Metadata:  7
Content:  chars: 111

[[2]]
<<PlainTextDocument>>
Metadata:  7
Content:  chars: 30

[[3]]
<<PlainTextDocument>>
Metadata:  7
Content:  chars: 154

[[4]]
<<PlainTextDocument>>
Metadata:  7
Content:  chars: 50

[[5]]
<<PlainTextDocument>>
Metadata:  7
Content:  chars: 60


base::as.character()로 sms_corpus 오브젝트를 문자열로 변환할 수 있다. 가령, 다음과 같습니다:


> base::as.character(sms_corpus[[1]])
[1] " Go until jurong point crazy.. Available only in bugis n great world la e buffet... Cine there got amore wat..."


R의 기본 함수(Built-in Functions) 중 base::lapply()라는 함수가 있는데(R에서 apply() 함수 시리즈를 모른다면 R에 대해 거의 모르는 것), Vector나 List에 대하여 지정된 함수를 일괄 실행하는 함수이다.


앞서 실행한 sms_corpus 오브젝트를 문자열로 일괄적으로(처음 5개) 변환하고자 한다면,


> base::lapply(sms_corpus[1:5], base::as.character)
$`1`
[1] " Go until jurong point crazy.. Available only in bugis n great world la e buffet... Cine there got amore wat..."

$`2`
[1] " Ok lar... Joking wif u oni..."

$`3`
[1] " Free entry in 2 a wkly comp to win FA Cup final tkts 21st May 2005. Text FA to 87121 to receive entry question(std txt rate)T&Cs apply 08452810075over18s"

$`4`
[1] " U dun say so early hor... U c already then say..."

$`5`
[1] " Nah I dont think he goes to usf he lives around here though"


과 같이 하면 됩니다.

소문자 변환

Spam/ham을 판별하는데 있어 대문자와 소문자를 구별하는 것은 의미가 없으며, 이로 인해 동일한 단어가 다르게 취급되지 않도록 모든 단어 뭉치들을 소문자로 변환합니다. 이를 위해 tm 패키지의 tm_map() 함수를 이용합니다.


tm_map() 함수는 단어 뭉치들을 변화하는 용도로 사용되며, 다음과 같이 사용합니다:


tm::tm_map( corpus, function() )

 

여기서 function()은 변환을 위한 함수이며, 역시 tm 패키지의 content_transformer()를 사용하여 변환한다.


Corpus를 소문자로 변환하는 명령은 다음과 같다:


sms_corpus_clean <- tm::tm_map(sms_corpus, tm::content_transformer(base::tolower))

그러면 소문자로 변환하기 전과 후를 비교해 보도록 하겠습니다.


변환 전

> base::lapply(sms_corpus[1:5], base::as.character)
$`1`
[1] " Go until jurong point crazy.. Available only in bugis n great world la e buffet... Cine there got amore wat..."

$`2`
[1] " Ok lar... Joking wif u oni..."

$`3`
[1] " Free entry in 2 a wkly comp to win FA Cup final tkts 21st May 2005. Text FA to 87121 to receive entry question(std txt rate)T&Cs apply 08452810075over18s"

$`4`
[1] " U dun say so early hor... U c already then say..."

$`5`
[1] " Nah I dont think he goes to usf he lives around here though"


변환 후

> base::lapply(sms_corpus_clean[1:5], base::as.character)
$`1`
[1] " go until jurong point crazy.. available only in bugis n great world la e buffet... cine there got amore wat..."

$`2`
[1] " ok lar... joking wif u oni..."

$`3`
[1] " free entry in 2 a wkly comp to win fa cup final tkts 21st may 2005. text fa to 87121 to receive entry question(std txt rate)t&cs apply 08452810075over18s"

$`4`
[1] " u dun say so early hor... u c already then say..."

$`5`
[1] " nah i dont think he goes to usf he lives around here though"


숫자 제거

종종 숫자가 큰 의미를 가질 수는 있겠으나, 그 의미를 기계가 판단하기는 어렵기 때문에 결국 숫자는 의미가 없으므로 제거합니다. 앞서 이용한 소문자 변환 명령과 유사한 방법으로 숫자를 제거할 수 있습니다:


sms_corpus_clean <- tm::tm_map(sms_corpus_clean, tm::removeNumbers)


Stopwords 제거

Stopwords는 관사, 대명사, 조동사, 전치사 등 ham/spam을 구별하는데 있어 아무런 도움이 되지 못하는 단어들을 의미합니다. 앞서 설명 것과 유사한 방법으로 Stopwords를 제거합니다:


sms_corpus_clean <- tm::tm_map(sms_corpus_clean, tm::removeWords, tm::stopwords())

세번째 파라미터인 tm::stopwords()는 단순히 단어들을 모아놓은 벡터입니다:


> tm::stopwords()
  [1] "i"          "me"         "my"         "myself"     "we"         "our"        "ours"      
  [8] "ourselves"  "you"        "your"       "yours"      "yourself"   "yourselves" "he"        
 [15] "him"        "his"        "himself"    "she"        "her"        "hers"       "herself"   
 [22] "it"         "its"        "itself"     "they"       "them"       "their"      "theirs"    
 [29] "themselves" "what"       "which"      "who"        "whom"       "this"       "that"      
 [36] "these"      "those"      "am"         "is"         "are"        "was"        "were"      
 [43] "be"         "been"       "being"      "have"       "has"        "had"        "having"    
 [50] "do"         "does"       "did"        "doing"      "would"      "should"     "could"     
 [57] "ought"      "i'm"        "you're"     "he's"       "she's"      "it's"       "we're"     
 [64] "they're"    "i've"       "you've"     "we've"      "they've"    "i'd"        "you'd"     
 [71] "he'd"       "she'd"      "we'd"       "they'd"     "i'll"       "you'll"     "he'll"     
 [78] "she'll"     "we'll"      "they'll"    "isn't"      "aren't"     "wasn't"     "weren't"   
 [85] "hasn't"     "haven't"    "hadn't"     "doesn't"    "don't"      "didn't"     "won't"     
 [92] "wouldn't"   "shan't"     "shouldn't"  "can't"      "cannot"     "couldn't"   "mustn't"   
 [99] "let's"      "that's"     "who's"      "what's"     "here's"     "there's"    "when's"    
[106] "where's"    "why's"      "how's"      "a"          "an"         "the"        "and"       
[113] "but"        "if"         "or"         "because"    "as"         "until"      "while"     
[120] "of"         "at"         "by"         "for"        "with"       "about"      "against"   
[127] "between"    "into"       "through"    "during"     "before"     "after"      "above"     
[134] "below"      "to"         "from"       "up"         "down"       "in"         "out"       
[141] "on"         "off"        "over"       "under"      "again"      "further"    "then"      
[148] "once"       "here"       "there"      "when"       "where"      "why"        "how"       
[155] "all"        "any"        "both"       "each"       "few"        "more"       "most"      
[162] "other"      "some"       "such"       "no"         "nor"        "not"        "only"      
[169] "own"        "same"       "so"         "than"       "too"        "very" 


따라서, 용도에 맞게 Stopwords를 변경하거나 추가하는 것이 가능합니다.


구두점 제거

역시 ham/spam 구별에 있어 도움을 주지 못하는 마침표(.), 쉼표(,), 따옴표(", ') 등등을 제거합니다.


sms_corpus_clean <- tm::tm_map(sms_corpus_clean, tm::removePunctuation)


형태소 분석 (Stemming)

영단어 중 동사의 다양한 형변화를 동일하게 취급하기 위해서는 Stemming(형태소 분석)이 필요합니다. Stemming은 자연어 처리(Natural Language Processing; NLP)에 필수적인 요소입니다.


영어는 되는데 그러면 "한국어는?"이라는 아쉬움을 달래기 위해 Twitter에서 만든 한국어 처리기가 있습니다. 아직 써보지는 않았지만 해당 GitHub 페이지에서 README.MD를 살펴보니 다양한 언어(프로그래밍 언어임)를 지원해 주고 있음을 알 수 있습니다.


가령, "turn"이라는 단어에 대한 Stemming 처리는 다음과 같습니다:


> SnowballC::wordStem(c("turn", "turned", "turning", "turns"))
[1] "turn" "turn" "turn" "turn"


그러나, 많은 영어의 동사들이 불규칙적인 것이 많아 잘 되지 않는 경우가 많습니다.


> SnowballC::wordStem(c("go", "went", "going", "goes"))
[1] "go"   "went" "go"   "goe"


Stemming 처리는 다음과 같습니다:


sms_corpus_clean <- tm::tm_map(sms_corpus_clean, tm::stemDocument)


White-space 제거

이제 불필요한 공란을 제거하도록 합니다.


sms_corpus_clean <- tm::tm_map(sms_corpus_clean, tm::stripWhitespace)


이로써 데이터 클린업 작업은 모두 마쳤습니다. 데이터 클린업 작업 전과 후의 데이터를 비교해 보도록 하겠습니다.



Before

> base::lapply(sms_corpus[1:3], as.character)
$`1`
[1] " Go until jurong point crazy.. Available only in bugis n great world la e buffet... Cine there got amore wat..."

$`2`
[1] " Ok lar... Joking wif u oni..."

$`3`
[1] " Free entry in 2 a wkly comp to win FA Cup final tkts 21st May 2005. Text FA to 87121 to receive entry question(std txt rate)T&Cs apply 08452810075over18s"


After

> base::lapply(sms_corpus_clean[1:3], as.character)
$`1`
[1] " go jurong point crazy available bugis n great world la e buffet cine got amore wat"

$`2`
[1] " ok lar joking wif u oni"

$`3`
[1] " free entry wkly comp win fa cup final tkts st may text fa receive entry questionstd txt ratetcs apply overs"


문장을 단어의 집합으로 변환

이제 데이터 가공 및 클린업 작업의 가장 마지막 단계인 문장(문자열)을 단어 집합으로 변환하는 작업이 남아 있습니다. tm 패키지의 DocumentTermMatrix() 함수를 이용하여 이와 같은 처리를 할 수 있습니다:


sms_dtm <- tm::DocumentTermMatrix(sms_corpus_clean)


그러나, 문장을 단어 집합으로 변환 시 단어 종류가 워낙 많기 때문에 모든 message가 포함하는 단어들을 각각 Feature로 처리하니 해당 Data Frame은 요소를 매우 듬성듬성 갖는 구조가 될 수 밖에 없습니다.


즉, 대부분의 요소는 비어 있게 됩니다.


가령, 다음 예를 살펴보도록 하겠습니다:


message[1]:  "hello everyone"

message[2]:  "thank you"

message[3]:  "hello world"


라고 하면, 이를 담는 Data Frame은,


"hello"

"everyone"

"thank"

"you"

"world"

message[1]

1

1

0

0

0

message[2]

0

0

1

1

0

message[3]

1

0

0

0

1


으로, 채워진 요소가 매우 듬성듬성 있음을 알 수 있습니다. sms_dtm을 살펴보면,


> sms_dtm
<<DocumentTermMatrix (documents: 5574, terms: 6699)>>
Non-/sparse entries: 43568/37296658
Sparsity           : 100%
Maximal term length: 40
Weighting          : term frequency (tf)

으로, 용어가 무려 6699개이며, Sparsity가 100%임을 알 수 있습니다.


한편, 데이터 클린업 작업을 여러 단계에 걸쳐서 왔지만, 단 한 방에 해결하는 방법이 있습니다. tm 패키지의 DocumentTermMatrix() 함수를 이용하는 방법입니다:


1
2
3
4
5
6
7
sms_dtm2 <- tm::DocumentTermMatrix(sms_corpus, control = list(
  tolower = TRUE,
  removeNumbers = TRUE,
  stopwords = TRUE,
  removePunctuation = TRUE,
  stemming = TRUE
))
cs


5. Training & Test 데이터 분할

총 5574개의 데이터 중, 75%는 Training 데이터로, 나머지 25%는 Test 데이터로 활용하기로 합니다. 우선 데이터의 개수(행의 개수)를 얻고,


counts <- base::dim(sms_dtm)[1]


이 중 75%의 개수만큼 샘플을 취합니다:


samples <- base::sample(1:counts, as.integer(counts*0.75))


얻은 샘플 요소에 대하여 Training 데이터를 지정하고,


sms_dtm_train <- sms_dtm[samples, ]


나머지는 Test 데이터로 지정합니다:


sms_dtm_test  <- sms_dtm[base::setdiff(1:counts, samples), ]


또한, Training 데이터에 대한 라벨(type)을 지정하고,


sms_train_labels <- sms_raw[samples, ]$type


Test 데이터에 대한 라벨을 지정합니다:


sms_test_labels  <- sms_raw[setdiff(1:counts, samples), ]$type


그리고나서, 각 데이터 세트의 spam/ham의 비율이 적절한지 살봅니다:


> base::prop.table(base::table(sms_train_labels))
sms_train_labels
      ham      spam 
0.8641148 0.1358852 


> base::prop.table(base::table(sms_test_labels))
sms_test_labels
      ham      spam 
0.8715925 0.1284075 


두 경우 모두 대략 ham의 비율이 86~87% 정도 수준이고, spam의 비율이 13% 수준입니다.


6. 데이터 시각화

데이터 시각화의 목적은, 데이터 속에 숨겨져 있는 패턴과 관계에 대한 무언가를 표현하는 것입니다. 데이터 시각화는 보기에도 좋아야 하고 좀 더 나은 결정을 할 수 있도록 도와야 하는 것입니다.


많은 종류의 데이터 시각화 방법이 있는데, 대표적인 것으로 Bar Chart, Histogram, Line Chart, Pie Chart, Frequency Wordle(또는 Word Clouds) 등이 있습니다.


Ham/spam은 단어를 다루는 것이므로 Word Clouds로 시각화하는 것이 가장 효과적일 것입니다.


이를 위해 wordcloud R 패키지를 설치합니다:


> install.packages("wordcloud")


이제 Word Cloud를 생성해 보도록 하겠습니다:


1
2
library(wordcloud)
wordcloud(sms_corpus_clean, min.freq = 50, random.order = FALSE)
cs



단어의 빈도에 비례하여 글자 크기가 크게 나옵니다. 표시되는 단어가 너무 많다면 min.frequency Argument를 이용하여 표시할 최소 빈도수를 정의합니다. 이 값은 표시할 최소의 빈도수를 의미하며, 예를 들어, min.frequency 값이 100이라면 100번 이상 등장하는 단어를 표시하도록 하는 것입니다:


wordcloud(sms_corpus_clean, min.freq = 100, random.order = FALSE, random.color = FALSE)

wordcloud는 단색이라 뭔가 심심하고 허전합니다. 좀 더 다채롭게 표현할 수 있도록 하는 Wordle 라이브러리로서 wordcloud2가 있습니다.


> install.packages("wordcloud2")


wordcloud2를 설치하고 Help 문서를 보면 입력할 data Argument가 전형적인 Data Frame임을 알 수 있다:


> head(demoFreq)
         word freq
oil       oil   85
said     said   73
prices prices   48
opec     opec   42
mln       mln   31
the       the   26


그러나, 현재 다루고 있는 데이터인 sms_dtm의 Class를 살펴보면,


> class(sms_dtm)
[1] "DocumentTermMatrix"    "simple_triplet_matrix"


아주 생소한 DocumentTermMatrix라는 것입니다. wordcloud2에서 데이터를 사용하려면 이를 Data Frame으로 변환해야 하므로, 이 과정을 살펴보록 합니다.


우선 sms_dtm을 Matrix로 변환하는데, 크기가 너무 크기 때문에 특별히 큰 데이터를 Matrix로 변환하는 함수를 사용합니다:


1
2
3
4
5
6
7
8
9
as.big.matrix <- function(x) {
  nr <- x$nrow;
  nc <- x$ncol;
  # nr and nc are integers. 1 is double. Double * integer -> double
  y <- matrix(vector(typeof(x$v), 1 * nr * nc), nr, nc);
  y[cbind(x$i, x$j)] <- x$v;
  dimnames(y) <- x$dimnames;
  return(y);
}
cs


과정은 sms_dtm을 Matrix로 변환하고 이를 다시 Data Frame으로 변환하는 것입니다.


> df <- base::as.data.frame(as.big.matrix(sms_dtm))


df의 차원을 살펴보면,


> base::dim(df)
[1] 5574 6699


행이 5574개이고, 열이 6699개인데, 행은 메시지, 열은 단어의 포함여부를 표시하고 있습니다.


이제 새로운 Data Frame을 정의하는데, wordcloud2의 Help 예제에 등장하는 demoFreq Data Frame의 형태를 갖도록 하기 위해 첫번째 열은 등장하는 단어를, 두번째 열은 각 단어의 빈도수를 저장합니다.


새로운 Data Frame의 이름은 wordFreq로 하였고, 이를 초기화합니다:


> wordFreq <- base::data.frame(matrix(ncol = 2, nrow = dim(df)[2]))


nrow = dim(df)[2]는 행의 수를 단어 종류의 수와 맞추기 위함입니다. 다음과 같이 Feature 항목의 이름을 정합니다:


> base::names(wordFreq) <- c("word", "freq")


이제 df에 저장되어 있는 각 단어를 불러오는데 이 단어들이 곧 df의 names와 같습니다:


> tmpName <- base::names(df)
> tmpName[1:100]
  [1] "£ £ homeown"                           "£ await"                               
  [3] "£ award"                                "£ bid"                                 
  [5] "£ bonus"                                "£ book"                                
  [7] "£ call"                                 "£ can"                                 
  [9] "£ cancel"                               "£ cash"                                
 [11] "£ cashto"                               "£ cd"                                  
 [13] "£ claim"                                "£ definit"                             
 [15] "£ dial"                                 "£ easter"                              
 [17] "£ enter"                                "£ get"                                 
 [19] "£ gift"                                 "£ good"                                
 [21] "£ guarante"                             "£ help"                                
 [23] "£ high"                                 "£ holiday"                             
 [25] "£ increment"                            "£ maxim"                               
 [27] "£ min"                                  "£ minapn"                              
 [29] "£ music"                                "£ next"                                
 [31] "£ pay"                                  "£ per"                                 
 [33] "£ play"                                 "£ pound"                               
 [35] "£ price"                                "£ prize"                               
 [37] "£ proze"                                "£ quiz"                                
 [39] "£ rcv"                                  "£ repli"                               
 [41] "£ reward"                               "£ scotsman"                            
 [43] "£ shop"                                 "£ sp"                                  
 [45] "£ sptyron"                              "£ sub"                                 
 [47] "£ summer"                               "£ tb"                                  
 [49] "£ that"                                 "£ tone"                                
 [51] "£ travel"                               "£ txtauction"                          
 [53] "£ txtauctiontxt"                        "£ uk"                                  
 [55] "£ unicef"                               "£ ur"                                  
 [57] "£ want"                                 "£ week"                                
 [59] "£ will"                                 "£ winner"                              
 [61] "£ wkli"                                 "£ worth"                               
 [63] "£ wwwtcbiz"                             "£ xmas"                                
 [65] "£award"                                 "£call"                                 
 [67] "£ea"                                    "£million"                              
 [69] "£minmobsmorelkpoboxhpfl"                "£month"                                
 [71] "£morefrmmob"                            "£msg"                                  
 [73] "£perweeksub"                            "£perwksub"                             
 [75] "£pm"                                    "£pmmorefrommobilebremovedmobypoboxlsyf"
 [77] "£week"                                  "£wk"                                   
 [79] "≫ evey"                                 "aah"                                    
 [81] "aaniy"                                   "aaooooright"                            
 [83] "aathi"                                   "aathilov"                               
 [85] "abbey"                                   "abdomen"                                
 [87] "abeg"                                    "abelu"                                  
 [89] "aberdeen"                                "abi"                                    
 [91] "abil"                                    "abiola"                                 
 [93] "abj"                                     "abl"                                    
 [95] "abnorm"                                  "abouta"                                 
 [97] "abroad"                                  "absenc"                                 
 [99] "absolut"                                 "abstract"


이 중 100개만 살펴보았더니 어떤 단어는 정상인데, 어떤 단어들 앞에는 특수문자(영국의 파운드로 보이는)가 앞에 포함되어 있는 것도 있습니다.


이를 제거하기 위해 tmpName으로부터 하나씩 요소를 불러와 알파벳 문자(a-z)만을 추출합니다. 이 기능을 수행하는 함수는 str_extract()이며 stringr 라이브러리를에 포함되어 있으므로 이것을 로딩합니다:


> library(stringr)


어떤 문자열 letters로부터 알파벳을 추출하는 명령어는 다음과 같습니다:


> stringr::str_extract(letters, "[a-z]+")


추출된 단어는 wordFreq Data Frame의 첫번째 열에 저장되며, 두번째 열로 저장될 빈도수는 df의 Column Sum으로 계산됩니다. 지금까지 설명한 부분에 대한 전체 코드는 다음과 같습니다:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
library(wordcloud2)
library(stringr)
 
as.big.matrix <- function(x) {
  nr <- x$nrow;
  nc <- x$ncol;
  # nr and nc are integers. 1 is double. Double * integer -> double
  y <- matrix(vector(typeof(x$v), 1 * nr * nc), nr, nc);
  y[cbind(x$i, x$j)] <- x$v;
  dimnames(y) <- x$dimnames;
  return(y);
}
 
df <- base::as.data.frame(as.big.matrix(sms_dtm))
wordFreq <- base::data.frame(matrix(ncol = 2, nrow = dim(df)[2]))
base::names(wordFreq) <- c("word""freq")
 
tmpName <- base::names(df)
 
for(i in 1:base::dim(df)[2]) {
  wordFreq[i,1<- stringr::str_extract(tmpName[i], "[a-z]+")
  wordFreq[i,2<- base::sum(df[,i])
}
cs


이제 wordcloud2를 이용하여 Wordle을 표시해 보도록 하겠습니다(빈도수 150번 이상 나오는 단어만 표시하였습니다) :


> freq <- 150
> wordcloud2(wordFreq[wordFreq[,2] > freq,])




빈도수 50번 이상 나오는 단어를 표시하면,


> freq <- 50
> wordcloud2(wordFreq[wordFreq[,2] > freq,])






ham에 대하여서만 Wordle을 생성하기 위해 다음과 같이 코드를 작성하였습니다:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
library(wordcloud2)
library(stringr)
 
sms_ham <- sms_raw[sms_raw$type == "ham",]
vs <- tm::VectorSource(sms_ham$message)
sms_ham_corpus <- tm::VCorpus(vs)
 
sms_ham_dtm <- tm::DocumentTermMatrix(sms_ham_corpus, control = list(
  tolower = TRUE,
  removeNumbers = TRUE,
  stopwords = TRUE,
  removePunctuation = TRUE,
  stemming = TRUE
))
 
as.big.matrix <- function(x) {
  nr <- x$nrow;
  nc <- x$ncol;
  # nr and nc are integers. 1 is double. Double * integer -> double
  y <- matrix(vector(typeof(x$v), 1 * nr * nc), nr, nc);
  y[cbind(x$i, x$j)] <- x$v;
  dimnames(y) <- x$dimnames;
  return(y);
}
 
df <- base::as.data.frame(as.big.matrix(sms_ham_dtm))
wordFreq <- base::data.frame(matrix(ncol = 2, nrow = dim(df)[2]))
base::names(wordFreq) <- c("word""freq")
 
tmpName <- base::names(df)
 
for(i in 1:base::dim(df)[2]) {
  wordFreq[i,1<- stringr::str_extract(tmpName[i], "[a-z]+")
  wordFreq[i,2<- base::sum(df[,i])
}
 
freq <- 50
wordcloud2(wordFreq[wordFreq[,2> freq,])
cs


Wordle 결과는 아래 이미지와 같습니다.





예상과 같이 ham에는 눈에 띄는 특이 단어 없이 일반적인 단어들이 보입니다. 마찬가지로 spam에 대해서만 Wordle을 생성하려면 다음과 같이 코드를 작성합니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
library(wordcloud2)
library(stringr)
 
sms_spam <- sms_raw[sms_raw$type == "spam",]
vs <- tm::VectorSource(sms_spam$message)
sms_spam_corpus <- tm::VCorpus(vs)
 
sms_spam_dtm <- tm::DocumentTermMatrix(sms_spam_corpus, control = list(
  tolower = TRUE,
  removeNumbers = TRUE,
  stopwords = TRUE,
  removePunctuation = TRUE,
  stemming = TRUE
))
 
as.big.matrix <- function(x) {
  nr <- x$nrow;
  nc <- x$ncol;
  # nr and nc are integers. 1 is double. Double * integer -> double
  y <- matrix(vector(typeof(x$v), 1 * nr * nc), nr, nc);
  y[cbind(x$i, x$j)] <- x$v;
  dimnames(y) <- x$dimnames;
  return(y);
}
 
df <- base::as.data.frame(as.big.matrix(sms_spam_dtm))
wordFreq <- base::data.frame(matrix(ncol = 2, nrow = dim(df)[2]))
base::names(wordFreq) <- c("word""freq")
 
tmpName <- base::names(df)
 
for(i in 1:base::dim(df)[2]) {
  wordFreq[i,1<- stringr::str_extract(tmpName[i], "[a-z]+")
  wordFreq[i,2<- base::sum(df[,i])
}
 
freq <- 50
wordcloud2(wordFreq[wordFreq[,2> freq,])
cs
 
결과는 아래 이미지와 같습니다:

 spam에는 spam 답게 "call", "free", "now" 등 광고에서 나올 법한 단어들이 많은 비중을 차지하고 있음을 알 수 있습니다. 이렇게 시각화를 하는 것은 패턴 등을 확인하는데 효과적임을 알 수 있습니다.


7. 빈도수가 높은 단어에 대한 지표 특성 추출

이제 spam/ham을 구별하는데 이용할 빈도수가 높은 단어에 대한 지표 특성을 추출합니다.


각 메시지를 통해 생성한 Document-Term Matrix의 단어들 중에는 전체에 걸쳐 5회 미만으로 등장하는 아주 희박한 단어들이 있는데, 이 단어들은 spam/ham을 구별하는 데 있어 큰 도움이 되지 않으므로 이들을 지표 특성에서 배제시킵니다.


tm 패키지의 findFreqTerms() 함수를 이용하여 특정 빈도수의 단어들을 추출합니다. 예를 들어, 최소 5회 이상 등장하는 단어들을 다음과 같이 추출할 수 있습니다:


> sms_freq_words <- tm::findFreqTerms(sms_dtm_train, lowfreq = 5)


이제 빈도수 5회 이상의 단어들에 대하여 Training 및 Test Dataset에 대한 Document-Term Matrix를 생성합니다:


> sms_dtm_freq_train <- sms_dtm_train[ , sms_freq_words]
> sms_dtm_freq_test <- sms_dtm_test[ , sms_freq_words]


8. Word 존재 여부를 Factor로 변환

현재 Training 및 Test Dataset은 각 Word의 빈도수에 대한 데이터를 저장하고 있었는데, 이제 각 Word의 포함여부에 대한 데이터로 변환할 것입니다.


C++ 문법 중 if문 대신 조건에 대하여 간단하게 처리하는 문법을 본 적이 있을 것입니다. 가령,


int a = 3;
int b = a > 1 ? 0 : 1

과 같이, a가 1보다 크면 b에 0을, 1과 같거나 보다 작으면 b에 1을 저장하는 것입니다. R에도 유사한 문법이 있는데, ifelse이며, 다음과 같은 용법을 갖습니다.


base::ifelse(CONDITION, YES, NO)


다음 코드는 Word의 포함여부에 대한 데이터로 변환하는 함수입니다.


convert_counts <- function(x) {
  x <- base::ifelse(x > 0, "Yes", "No")
}


9. Training & Test 데이터 생성

base::apply() 함수를 이용하여 Training 및 Test Document-Term Matrix의 열(Column)에 convert_counts() 함수를 일괄 적용합니다.


> sms_train <- base::apply(sms_dtm_freq_train, MARGIN = 2, convert_counts)
> sms_test  <- base::apply(sms_dtm_freq_test, MARGIN = 2, convert_counts)

base::apply() 함수의 Arguments 중 MARGIN = 2는 Matrix의 열(Column)에 적용한다는 의미이며, MARGIN이 1이라면 행(Row)에 적용한다는 의미입니다.


10. Naive Bayes를 이용한 Classifier 생성

e1071 패키지는 TU Wien(Technische Universität Wien)의 확률 이론 그룹에서 만든 확률/통계 패키지입니다.


이 패키지를 설치합니다:


> install.packages("e1071")


이 패키지에 naiveBayes() 함수가 있는데 이를 이용하여 Naive Bayes Classifier를 생성합니다:


library(e1071)
sms_classifier <- e1071::naiveBayes(sms_train, sms_train_labels)

이제 Training Model 생성이 완료 되었으므로 이 모델의 성능을 Test Dataset을 통해 평가한합니다:


sms_test_pred <- stats::predict(sms_classifier, sms_test)


11. 예측 및 결과 평가

예측 모델을 통해 생성된 결과 데이터(sms_test_pred)를 gmodels 패키지의 crossTable() 함수를 이용하여 출력해 보도록 합니다.


우선 gmodels 패키지를 설치합니다:


> install.packages("gmodels")


crossTable() 함수를 이용하여 출력합니다:


library(gmodels)
gmodels::CrossTable(sms_test_pred,
                    sms_test_labels,
                    prop.chisq = FALSE,
                    prop.t = FALSE,
                    prop.r = FALSE,
                    dnn = c('predicted', 'actual'))


출력 결과는 다음과 같습니다:


Total Observations in Table:  1394 

 
             | actual 
   predicted |       ham |      spam | Row Total | 
-------------|-----------|-----------|-----------|
         ham |      1211 |        28 |      1239 | 
             |     0.997 |     0.156 |           | 
-------------|-----------|-----------|-----------|
        spam |         4 |       151 |       155 | 
             |     0.003 |     0.844 |           | 
-------------|-----------|-----------|-----------|
Column Total |      1215 |       179 |      1394 | 
             |     0.872 |     0.128 |           | 
-------------|-----------|-----------|-----------|


결과를 살펴보면, 총 1394개의 Observation 들 중 올바른 예측 (실제 ham을 ham으로 예측하고, 실제 spam을 spam으로 예측한 것들)은 한 확률은,


(1211 + 151) / 1394 = 0.9770445 = 97.7(%)


무려 정답률이 98%에 가깝게 나오므로, Naive Bayes 예측 모델이 상당히 잘 맞는다는 것을 알 수 있습니다.

(이것이 Naive Bayes가 널리 사용되는 이유이기도 합니다.)


ham으로 예측한 것들 중 실제로는 spam인 확률은,


28 / 1239 = 0.02259887 = 2.26(%)


이며,


spam으로 예측한 것들 중 실제로는 ham인 확률은,


4 / 155 = 0.02580645 = 2.6(%)


입니다.


두 가지 경우 중, 사용자 입장에서 보다 위험한 경우는, 실제로는 ham인데 spam으로 예측되는 경우입니다. 이를 위해 좀 더 성능을 개선하려면 naiveBayes() 함수의 Laplace 값을 정의합니다.


sms_classifier2 <- naiveBayes(sms_train, sms_train_labels, laplace = 2)
sms_test_pred2 <- predict(sms_classifier2, sms_test)
CrossTable(sms_test_pred2, sms_test_labels,
           prop.chisq = FALSE, prop.t = FALSE, prop.r = FALSE,
           dnn = c('predicted', 'actual'))


결과는 다음과 같습니다:


Total Observations in Table:  1394 

 
             | actual 
   predicted |       ham |      spam | Row Total | 
-------------|-----------|-----------|-----------|
         ham |      1212 |        34 |      1246 | 
             |     0.998 |     0.190 |           | 
-------------|-----------|-----------|-----------|
        spam |         3 |       145 |       148 | 
             |     0.002 |     0.810 |           | 
-------------|-----------|-----------|-----------|
Column Total |      1215 |       179 |      1394 | 
             |     0.872 |     0.128 |           | 
-------------|-----------|-----------|-----------|



이로써 Naive Bayes 이론을 이용한 Spam Filter 제작하는 법에 대하여 알아보았습니다.

Comments