在 iOS App 中進行自然語言處理:初探 NSLinguisticTagger


此文章轉載自作者網誌「QCLog」,由作者Qing-Cheng Li授權轉載。Qing-Cheng Li 為自然語言處理實驗室畢業的研究生、目前專職撰寫原生行動應用程式的軟體工程師。本文會詳細講解 NSLinguisticTagger以及如何在 iOS 應用程式裡進行自然語言處理。另外,本文所有圖片來源自WWDC的影片

作為一位自然語言處理實驗室畢業的研究生、目前專職撰寫原生行動應用程式的軟體工程師,今年 Apple 的 WWDC 有一項議程特別引起我的注意:「自然語言處理與你的應用程式」(Natural Language Processing and your Apps)。我的所學(講的好像我真的會自然語言處理一樣)跟我的工作(講的一副我真的會寫 App 的樣子)終於產生連結了嗎?

所以趁著公司被賣掉的週末就把影片開來配飯吃,順便試著玩玩看這次新開放(這我倒是不太確定了,從文件來看部分 APIs 早在今年才要推出的 iOS 11 之前就已經支援)的自然語言處理 APIs,加減練習一下我越看越覺得好看的 Swift。

自然語言處理流程

這段 WWDC 的議程首先介紹了大致的自然語言處理流程,首先當然是要有要被處理的文本,可能透過鍵盤打字輸入、手寫輸入或語音輸入辨識之後得到一段文字。

再來可能要先辨識這段語言是哪一個語言,是英文、中文還是克林貢語

知道語言之後根據語言的特性,拿勝利寶劍🗡來斷開魂結,這一步叫做斷詞(Tokenization),把一個文本斷成一些基本的段落,像是一個個詞彙。例如把「你猜我一共環遊世界幾次?」斷成「你」、「猜」、「我」、「一共」、「環遊」、「世界」、「幾」、「次」這樣的單字單位。英文在這個問題上相對簡單很多,因為英文的字與字中間是用空白隔開的,但中文字全都連在一起,於是有各式各樣的斷詞方法。

斷詞之後為了進行後續的應用,我們也許也會想要知道每個詞的詞性,所以有一步叫做詞性標注(POS, Part of Speech),例如剛剛那句話裡面的「環遊」是動詞。

在某些語言之中的動詞或名詞會有不同的表現形式,像是動詞時態、名詞單複數型等等,經過一陣努力的斷詞處理之後,或許需要知道原來「playing」、「plays」都是「play」 的變化,其實是同一個或者說是極為近似的概念;「cats」跟「cat」是指涉同一種動物等等。所以會有一個詞形還原(Lemmatization)的步驟。

經過上述處理之後,我們也可能需要知道是不是有些詞是地名、人名、組織名稱之類的以利後續利用,所以需要具名實體辨識(NER, Named Entity Recognition)。像是偵測出「滿滿的大平台」的「大平台」是一個位在日本箱根的地名。(這個例子是亂寫的)

所以在一陣猛烈的自然語言處理之後我們就可以把原本的文本輸入弄成一堆帶有各式各樣標記的詞彙甚至弄出一個句子的樹狀結構,再來就可以直接利用或者拿去一陣猛烈的機器學習去理解語意了。

在 iOS 應用程式裡進行自然語言處理

Natural Language Processing Support

為了讓 App 可以方便地進行自然語言處理,Apple 這次提供給開發者一組方便的自然語言處理 APIs,目前看來主要是 NSLinguisticTagger 這個物件。(我猜他們應該之前就有類似的內部 APIs 了,畢竟 Siri 也推出這麼多年了,或許是搭著 CoreML 一起把自然語言處理的 APIs 放出來讓大家玩玩)

稍回查了一下,看來早在 2012 年就已經有 NSLinguisticTagger 了,NSHipster 上還有一篇介紹文,所以我現在是在寫身體健康的。並不太清楚這次 WWDC 特別介紹是不是多了什麼新功能,至少從文件上看斷詞、詞性標記跟具名實體辨識是已經有的功能,語言辨識是新提供,並且在 Swift 4.0 有一套新的介面。亂猜一下感覺是在效能跟語言支援度有提升吧?

以目前最新的文件來看,NSLinguisticTagger 支援下列功能:

  • 語言辨識
  • 斷詞
  • 詞性標記
  • 詞形還原
  • 具名實體辨識

目前詞性標記、詞形還原跟具名實體辨識根據投影片上的介紹只支援了英文、法文、義大利文、德文、西班牙文、葡萄牙文、俄文、土耳其文。

nlp-wwdc-nslinguistictagger

效能上,蘋果強調這是針對機器最佳化的、可多執行緒處理,由於是在機器上獨立運作的,因此蘋果也說這是很有隱私的。

在 WWDC 的影片中分別示範了兩個應用,一個是利用斷詞與詞形還原讓搜尋可以透過單字比對直接找到帶有文字描述的照片,而另外則是應用具名實體辨識來分類社群貼文。

使用 NSLinguisticTagger

參考這次議程的投影片跟文件,我用 Swift 4.0 在 Xcode 9.0 Beta 的 Playground 小試了一下 NSLinguisticTagger 的各種應用。

在 Swift 4.0 / iOS 11+ 環境下,NSLinguisticTagger 有了一個新的建構子,將原先帶入的 tag 字串改成新的 structure NSLinguisticTagScheme,裡面幾個常數大概就是用來指定下列各種用途。

語言辨識

func languageTest(sentence: String) {
    let tagger = NSLinguisticTagger(tagSchemes: [.language], options: 0)
    tagger.string = sentence
    if let language = tagger.dominantLanguage {
        print("\(language)")
    } else {
        print("Unknow.")
    }
}

NSLinguisticTagScheme.language 作為參數來初始化 NSLinguisticTagger,將欲判定語言的字串放入 tagger 的 string 後,取用 dominantLanguage 取得字串的語言類別,回傳的語言類別是符合 BCP 47 規範的語言字串,像是正體中文會回傳 zh-Hant,英文回傳 en。從文件上看來這個問語言的功能應該是這次新增的。

languageTest(sentence: "我們現在談的不是五十萬,是五百萬!")
// "zh-Hant"
languageTest(sentence: "We are not talking about five hundred thousand, is five million!")
// "en"
languageTest(sentence: "社会のセグメントは、その職務を遂行します")
// "ja"

斷詞

幾乎是中文語言處理必定要面對的問題,以前小時候在研究室的時候會用史丹佛大學的 Stanford Parser,古早年代 Yahoo 還有一個 API 叫做斷章取義,但後來收掉了。聽說現在的小朋友用結巴的比較多。當然我覺得中央研究院的 CKIP 也不錯,就是操作介面難用了些。 (其實忘了 NLTK 是不是應該也能做中文斷詞)

現在有了新的選擇(好吧從文件看是 2012 年就有了,這是我進實驗室的那一年,為什麼我都不知道有這東西😭)NSLinguisticTagger。初始化帶入的 tag scheme 是 tokenType,一樣透過 tagger 的 string 把要斷的字串塞進去,使用 enumerateTags(in:unit:scheme:options:using:) 來拿結果。

func tokenize(sentence: String) -> [String] {
    var tokens:[String] = [String]()

    let tagger = NSLinguisticTagger(tagSchemes: [.tokenType], options: 0)

    tagger.string = sentence
    let range = NSMakeRange(0, sentence.utf16.count)
    let options: NSLinguisticTagger.Options = [.omitWhitespace, .omitPunctuation]

    tagger.enumerateTags(in: range, unit: .word, scheme: .tokenType, options: options) { (tag, tokenRange, stop) in
        let word = (sentence as NSString).substring(with: tokenRange)
        tokens.append(word)
    }

    return tokens
}

這邊先設定選項,去掉標點符號(.omitPunctuation)跟空白(.omitWhitespace)。告訴 tagger 我要的單位是字(其他還可以填文章、段落、句子,請參閱這裡看更多單位),幫我做斷詞(scheme: .tokenType)。

結果就會拿到 token 在原先句子裡頭的範圍,就可以當成斷詞之後的結果。看起來還行還行。

let texts: [String] = ["你猜我一共環遊世界幾次?",
                       "qcl誠徵女友",
                       "我想交個女朋友"]
for text in texts {
    let tokens = tokenize(sentence: text)
    print("\(text) --> \(tokens)")
}

// Output:
// 你猜我一共環遊世界幾次? --> ["你", "猜", "我", "一共", "環遊", "世界", "幾", "次"]
// qcl誠徵女友 --> ["qcl", "誠徵", "女友"]
// 我想交個女朋友 --> ["我", "想", "交", "個", "女朋友"]

詞性標記

一樣繼續使用 NSLinguisticTagger,tag scheme 用 lexicalClass。跟斷詞一樣,給定範圍與選項,使用 enumerateTags 來取得結果。不囉唆直接上程式碼:

func POS(sentence: String) {
    let tagger = NSLinguisticTagger(tagSchemes: [.lexicalClass], options: 0)

    tagger.string = sentence
    let range = NSMakeRange(0, sentence.utf16.count)
    let options: NSLinguisticTagger.Options = [.omitWhitespace, .omitPunctuation]

    tagger.enumerateTags(in: range, unit: .word, scheme: .lexicalClass, options: options) { (tag, tokenRange, stop) in
        let word = (sentence as NSString).substring(with: tokenRange)
        if let tag = tag {
            print("\(word) : \(tag.rawValue)")
        }
    }
}

上面的範例程式直接把詞性的字串印出來,實際使用上建議還是使用 NSLinguisticTag 的常數,像是 NSLinguisticTag.noun 當名詞來使用這樣。詳細的詞性列表請按這裡看更多。

POS(sentence: "qcl wants a girlfriend.")

// Output:
// qcl : Pronoun
// wants : Verb
// a : Determiner
// girlfriend : Noun

詞形還原

繼續使用 NSLinguisticTagger,使用 lemma 作為 tag scheme。剩下基本上就都跟斷詞一樣了,用 enumerateTags 來拿結果。一樣直接看程式碼:

func lemmatize(sentence: String) {
    let tagger = NSLinguisticTagger(tagSchemes: [.lemma], options: 0)
    tagger.string = sentence
    let range = NSMakeRange(0, sentence.utf16.count)
    let options: NSLinguisticTagger.Options = [.omitWhitespace, .omitPunctuation]
    
    tagger.enumerateTags(in: range, unit: .word, scheme: .lemma, options: options) { (tag, tokenRange, stop) in
        let word = (sentence as NSString).substring(with: tokenRange)
        if let lemma = tag?.rawValue {
            print("\(word) -> \(lemma)")
        } else {
            print("\(word) -> ???")
        }
    }
}

在這裡文件的描述是會回傳「A stem of the word, if available」,就是如果有詞幹的話就回傳,不然就沒有。所以可以看到拿 tag 的地方,會先看有沒有 rawValue 再把它拿出來。

lemmatize(sentence: "qcl wants a girlfriend.")

// Output
// qcl -> ???
// wants -> want
// a -> a
// girlfriend -> girlfriend

具名實體辨識

基本上呢,跟前面的都一樣使用 NSLinguisticTagger(寫到這裡 Linguistic 這個字也該背起來了),tag scheme 是 nameType。當然也跟先前都一樣,使用 enumerateTags 來取得結果。

func NER(sentence: String) {
    let tagger = NSLinguisticTagger(tagSchemes: [.nameType], options: 0)
    tagger.string = sentence
    let range = NSMakeRange(0, sentence.utf16.count)
    let options: NSLinguisticTagger.Options = [.omitWhitespace, .omitPunctuation, .joinNames]
    let tags: [NSLinguisticTag] = [.personalName, .placeName, .organizationName]
    
    tagger.enumerateTags(in: range, unit: .word, scheme: .nameType, options: options) { (tag, tokenRange, stop) in
        if let tag = tag, tags.contains(tag) {
            let name = (sentence as NSString).substring(with: tokenRange)
            print("\(name) is \(tag.rawValue)")
        }
    }
}

在這裡的選項多加了一個 NSLinguisticTagger.Options.joinNames,文件的描述是說通常多個字會被當成多個 token 回傳,如果家了這個選項可以讓多字組成的名字當成一個 token 被辨識。

nameType 透過 enumerateTags 拿回的 NSLinguisticTag 目前有三種,分別是人名(.personalName)、地名(.placeName)與組織名(.organizationName)。範例程式裡確認 tag 是這三種才把被辨識到的具名實體及其類別一起印出來。

NER(sentence: "Taiwan is an independent country, not a part of China.")
// Output:
// Taiwan is PlaceName
// China is PlaceName
NER(sentence: "Taiwan is not a part of People's Republic of China.")
// Output:
// Taiwan is PlaceName
// China is PlaceName
NER(sentence: "Republic of China and People's Republic of China are two counties.")
// Output:
// China is PlaceName
// People's Republic of China is OrganizationName
NER(sentence: "Ohiradai is located in Japan.")
// Output:
// Japan is PlaceName
NER(sentence: "Marissa Mayer was the CEO of Yahoo.")
// Output:
// Marissa Mayer is PersonalName
// Yahoo is OrganizationName

在前三個例子之中,第一句「臺灣是一個獨立國家,不是中國的一部分」,它辨識到「臺灣」和「中國」是兩個地名;第二句「臺灣不是中華人民共和國的一部分」,一樣辨認到「臺灣」是地名,但沒有辨識出「中華人民共和國」🇨🇳作為一個組織,只認到「中國」當作地名;第三句「中華民國和中華人名共和國是兩個國家」中,中華民國裡的「中國」被當作地名辨識,而「中華人民共和國」🇨🇳卻作為一個組織完整地出現。算是蠻有意思的結果。

第四句「大平台位在日本」中,NSLinguisticTagger 並未認出「大平台」,只認出了「日本」🇯🇵作為一個地名。

最後一句「Marissa Mayer 是 Yahoo 的 CEO」認出了梅姐的名字以及 Yahoo 作為組織名稱。

我猜大概因為是本地離線的辨識模型,這種(不失一般性)需要靠字典的應該就是本地有個辭典可以查,所以效果應該有某種程度的辨識,或者本地有個已經訓練好的模型可以幫助判斷吧,但畢竟還是需要辭典的。前三組的結果讓我感覺也不盡然只靠查辭典,真的有點意思。

結語

以上簡單嘗試了 NSLinguistTagger 支援的自然語言處理功能:語言辨識、斷詞、詞性標記、詞形還原與具名實體辨識。今年另外一場 WWDC 的議程「Core ML in depth」中有進一步結合 Core ML 與 NSLinguistTagger 的應用,之後如果有空應該會把那場拿來再寫一篇。

本文由作者Qing-Cheng Li授權轉載。Qing-Cheng Li 為自然語言處理實驗室畢業的研究生、目前專職撰寫原生行動應用程式的軟體工程師。

此文章為客座或轉載文章,由作者授權刊登,AppCoda編輯團隊編輯。有關文章詳情,請參考文首或文末的簡介。

blog comments powered by Disqus
Shares
Share This