形態素解析器をSwiftで試作してみた

 2019年は、GWが10連休になるなど、長期連休だったこともあり、せっかくだからその連休を使って、Swiftを覚えてみることにしました。そのときの題材として選んだのが、普段お世話になっている形態素解析器です。Swiftで書くからといって、iOSやmacOSに特化しているような開発対象でもないので、Swiftが対応しているmacOS・Linuxで動作することを目標としました。年末(2019年)のAdvent Calendarで形態素解析器を自作されている方の記事を読んだので、せっかくだから、自分も苦労したところなどを記事にまとめてみることにしました。そのため、実用に耐えるようなレベルではありません。実用にはアレとかアレを使ってください。

Why Swift ?

 2019年のTensorFlow Dev Summit 2019の動画を、イベントから1日遅れぐらいでYoutubeで観ていました。(いつか参加してみたい。)
 その中でTensorFlowの対応言語として、Swiftが紹介されていて、DNNへの応用の記述がメインだったので、その部分にはあまり興味がなかったのですが、TensorFlowをSwiftから使用できることに対しては興味があり、これはSwiftをやるしかない!と思いSwiftを習得することにしました。アノ言語は、強い型付け言語ではないため、強い型付け言語でMLのコードを書きたいという思いからです。そのため、iOS・macOSのアプリを対象としたSwiftの作法については未だに知りません。あくまで、言語としてSwiftの文法と設計作法を身につけることを目的としました。

開発環境

 iOS・macOS以外の用途として、Swiftを使用しようとする奇特な人はなかなかいないようで、あまり情報のない中、手探りで開発環境を整え始めました。

・macOS 10.14
・Swift 5.0
・Xcode 10

 開発開始時点(2019年5月)では、上記環境でした。その後Catalina(10.15)やXcode 11がリリースされてますが、その環境でもビルドできることは、この記事を書き始める前に確認しました。また、ビルドはLinux(Ubuntu18.04)上でも行っていたので、そちらでの動作も確認しました。macOS上で開発するときも、ビルドはコマンドラインからmakeコマンドを叩いて実行していました。エディタはXcodeを使用しました。Appleが公開しているXcodeではなくSwift for TensorFlow(S4TF)で公開しているXcodeをダウンロードして設定しました。

参考文献

 参考文献は、以下の2冊です。どちらもNLPerにとってはとても有名な本だと思います。実際に作ってみると、本では記載されていないけど、実際に動作させるには必要な部分などがあり、その部分がわかった(試行錯誤して、いろいろ調べて実装した)のがとても良かったです。全体の形態素解析器の開発の流れは、工藤さんの本を参考にして、コスト計算部分は徳永さんの本を参考にしました。

開発方針

 形態素解析器を開発するにあたり、既存のコスト計算済みのモデルを使用することも可能だったのですが、今回は実用的な形態素解析器を作るというよりは、Swiftの習得が目的であったため、できるだけフルスクラッチしてしまおうと思いました。誰に迷惑をかけるわけでもないので。
 ゴールまでの道のりは、最終的に以下になりました。開発中は試行錯誤しながらだったので、前後しながら開発していました。先に進んでは、別のところに戻って再実装・テストを繰り返していました。

・辞書
・ダブル配列
・学習データ
・ラティス構築
・コスト計算
・形態素予測

辞書

 形態素解析器を使用したことがある人は、ご存知だと思いますが、形態素解析器自体は辞書を内部で保持しているわけではないので、辞書を変更して使用することができます。よく使用される辞書としてはIPAdic・NEologd・UniDicなどがあり、どの辞書を選ぶかはそれぞれの特徴から選定を行うことになると思います。(辞書の特徴などについては、工藤さんの本に記載されていました。)今回は、UniDicを使用しました。もっとも細かい単位で分割するのがベーシックなものとして良いのでは?というあまり深い選定理由ではないです。UniDicは以下のリンクからダウンロード可能です。

 ダウンロードしたファイルを展開し、辞書のCSV(lex.csv)から、以下のコードで実際に使用したい部分だけを抽出しました。「pos1、pos2、pos3」は名詞の分類です。「名詞、普通名詞、一般」などのよく見る分類を抽出しています。実際にUniDicの辞書を見てもらえば、どのあたりの情報を抽出しているのかわかると思います。

import Foundation

final public class UnidicConverter {   

   static public func makeWordDic(filePath: String, writePath: String) {
       FileManager.default.createFile(atPath: writePath, contents: nil, attributes: nil)
       guard let fh = FileHandle(forWritingAtPath: writePath) else {
           fatalError("Couldn't open \(writePath).")
       }
       fh.truncateFile(atOffset: 0)
       
       var kv: [String: [String]] = [:]
       
       do {
           let contents = try String(contentsOfFile: filePath)
           contents.enumerateLines { (line, stop) -> () in
               let elem = line.trimmingCharacters(in: .newlines).components(separatedBy: ",")
               let (surface, pos1, pos2, pos3) = (elem[0], elem[4], elem[5], elem[6])
              
               if Int(pos1) == nil {
                   let pos = "\(pos1)-\(pos2)-\(pos3)"
                   if kv.keys.contains(surface) {
                       if !kv[surface]!.contains(pos) {
                           kv[surface]!.append(pos)
                       }
                   } else {
                       kv[surface] = [pos]
                   }
               }
           }
       } catch {
           fatalError("Couldn't convert.")
       }
       
       for (surface, poses) in kv {
           for pos in poses {
               fh.write("\(surface)\t\(pos)\n".data(using: .utf8)!)
           }
       }
       
       fh.closeFile()
   }
}

 これで必要な情報をまとめたファイルが出来上がりました。(unidic.defという名前にしました。)

ダブル配列

 ダブル配列の解説については、他にいろんな方が優れた資料を公開されているので、ここでは説明しません。工藤さんの本でも、わかりやすく説明されています。

import Foundation

public struct Feature {
   var looCost: Int // likelihood of occurrence cost.
   var pos: String
}

struct SerializedDA: Codable {
   var dic: [String]
   var codeTable: [String]
   var base: Array<Int>
   var check: Array<Int>
}

/// Class of Double Array implementation.
final public class DoubleArray {
   typealias ERROR = DEF.DoubleArray.Error

   private let TERMINATE_CHAR: Character = "#"
   private var TERMINATE_CHAR_ID: Int = -1
   private let ROOT_IDX = 1

   private var dicKV: [String: Int] = [:]
   private var dic: [String] = []
   private var codeTableCtoI: [Character: Int] = [:]
   private var codeTableItoC: [Int: Character] = [:]

   private var firstChars: [Character] = []

   private var base: Array<Int> = []
   private var check: Array<Int> = []
   private var lookupTable: [String: [String]] = [:]

   private var maxCheckNum: Int = 0
   private var startArrayNum: Int = 2

   public init() {}
   
   /// Read dic file.
   ///
   /// - Parameter filePath: The path to dic file.
   public func readDic(_ dicPath: String) {
       var uniqWords: Set<String> = []
       var rawDict: [String] = ["#"] // Index starting 1 # is placeholder.
       var codeTableChar: Set<Character> = []
       var firstChars: Set<Character> = []

       do {
           var linum = 0
           let contents = try String(contentsOfFile: dicPath)
// next line isn't work on Linux swift compiler.
//            contents.enumerateLines { (line, stop) -> () in
           var lines = contents.components(separatedBy: .newlines)
           lines.removeLast()
           for line in lines {
               let elem = line.components(separatedBy: "\t")
               uniqWords.insert(elem[0])
               linum += 1
               print(linum, separator: "", terminator: "\r")
           }
       } catch {
           fatalError("Couldn't build double array.")
       }

       rawDict += Array(uniqWords)

       // For codeTable and firstChars
       for surface in rawDict {
           for (i, c) in surface.enumerated() {
               codeTableChar.insert(c)
               if i == 0 {
                   firstChars.insert(c)

                   if self.lookupTable.keys.contains(String(c)) {
                       self.lookupTable[String(c)]!.append(surface)
                   } else {
                       self.lookupTable[String(c)] = [surface]
                   }
               }
           }
       }

       self.dic = rawDict.sorted()

       ///// Not necessary ? absolutely inserted #.
       var codeTableCharArray = Array(codeTableChar)
       if nil == codeTableCharArray.firstIndex(of: "#") {
           codeTableCharArray += ["#"]
       }

       let sortedCodeTable = codeTableCharArray.sorted(by: <)
       self.createConvertableHash(sortedCodeTable)
       self.TERMINATE_CHAR_ID = self.codeTableCtoI[self.TERMINATE_CHAR]!
       self.firstChars = firstChars.sorted()
   }

   private func createConvertableHash(_ sortedCodeTable: [Character]) {
       for (i, c) in sortedCodeTable.enumerated() {
           self.codeTableCtoI[c] = i
           self.codeTableItoC[i] = c
       }
   }

   private func convertCodeTableToArray() -> [String] {
       return self.codeTableCtoI.keys.sorted(by: <).map { String($0) }
   }

   /// Build double array.
   public func buildDic(_ filePath: String = "", _ dicPath: String = "") {
       self.dicKV = createDicKV(sortedDicArray: self.dic)
       logger.info("dic.count: \(self.dic.count)")
       let arraySize = 10_000 // initial size of base and check array. it will extend to enough capacity automatically in extendArray func.
       self.maxCheckNum = arraySize
       initArray(size: arraySize) // base, check initialize
       initalInsert() // insert first chars have root idx.
       makeTree()
       exportData(filePath, dicPath)
   }

   public func loadData(_ filePath: String) {
       print(filePath)
       do {
           logger.info("loading da data...")
           let jsonText = try String(contentsOfFile: filePath).data(using: .utf8)!
           let json = try JSONDecoder().decode(SerializedDA.self, from: jsonText)
           self.dic = json.dic
           self.base = json.base
           self.check = json.check

           let codeTable = json.codeTable.map { Character($0) }
           self.createConvertableHash(codeTable)
       } catch {
           fatalError("Couldn't load from json.")
       }
   }

   private func exportData(_ filePath: String, _ dicPath: String) {
       let codeTable = self.convertCodeTableToArray()
       let data = try! JSONEncoder().encode(SerializedDA(dic: self.dic, codeTable: codeTable, base: self.base, check: self.check))
       do {
           try String(data: data, encoding: .utf8)!.write(toFile: filePath, atomically: true, encoding: .utf8)
       } catch {
           fatalError("Couldn't export to josn file.")
       }
   }

   /// Initialize base, check, lable arrays.
   ///
   /// - Parameter size: The size of array.
   private func initArray(size: Int) {
       self.base = [Int](repeating: 0, count: size)
       self.check = [Int](repeating: 0, count: size)
   }

   /// Insert initial values to check and label of all of prefix char.
   private func initalInsert() {
       self.base[1] = ROOT_IDX
       for firstChar in self.firstChars {
           let t = ROOT_IDX + self.getCode(firstChar)!
           check[t] = ROOT_IDX
       }
   }

   private func extendArray(touchID: Int) {
       if self.maxCheckNum <= touchID {
           let extendSize = self.maxCheckNum / 2
           self.check += [Int](repeating: 0, count: extendSize)
           self.base += [Int](repeating: 0, count: extendSize)
           self.maxCheckNum += extendSize
       }
   }

   /// Make a array has index values.
   ///
   /// - Parameter cs: Array of text.
   /// - Returns: function of array includes index value.
   private func searchNextId(cs: [Character]) -> (Int) -> [Int] {
       return {
           var idx: [Int] = []
           for c in cs {
               let checkID = $0 + self.getCode(c)!
               self.extendArray(touchID: checkID)
               let nextID = self.check[checkID]

               if nextID != 0 {
                   return [-1]
               }
               idx.append(nextID)
           }
           return idx
       }
   }

   private func getBaseValue(cs: [Character]) -> Int {
       for i in self.startArrayNum..<self.maxCheckNum {
           var count = 0
           for c in cs {
               let checkID = i + self.getCode(c)!
               self.extendArray(touchID: checkID)
               let nextID = self.check[checkID]

               if nextID != 0 {
                   break
               }

               count += 1
           }
           if count == cs.count {
               self.startArrayNum = i + 1
               return i
           }
       }
       fatalError("Never come here.")
   }

   /// Make a double array tree.
   private func makeTree() {
       for s in firstChars {
           let t = ROOT_IDX + self.getCode(s)!
           insertTree(rootChar: String(s), rootIdx: t)
       }
   }

   /// Make a array has prefix char.
   ///
   /// - Parameter prefix: The prefix includes word of prefix in return array.
   /// - Returns: Array of string has prefix char from dic.
   private func listHas(prefix: String) -> [String] {
       if let l = self.lookupTable[String(prefix.prefix(1))] {
           return l.filter { $0.hasPrefix(prefix) }
       } else {
           return self.dic.filter { $0.hasPrefix(prefix) }
       }
   }

   /// Insert values to double array tree.
   ///
   /// - Parameters:
   ///   - rootChar: The root char is prefix char.
   ///   - rootIdx: The ID is current position index of array.
   private func insertTree(rootChar: String, rootIdx s: Int) {
       let depth = rootChar.count + 1
       let targets = listHas(prefix: rootChar) // TODO: use Hash

       var childList: Set<Character> = []
       for target in targets {
           if depth <= target.count {
               childList.insert(Array(target)[rootChar.count])
           } else {
               childList.insert(self.TERMINATE_CHAR)
           }
       }

       let x = getBaseValue(cs: Array(childList))
       base[s] = x

       for c in childList {
           let t = x + getCode(c)!
           check[t] = s
       }

       for c in childList {
           let t = x + getCode(c)!
           let nextRootChar = "\(rootChar)\(c)"
           
           if c == self.TERMINATE_CHAR {
               if let dicID = dicKV[rootChar] {
                   base[t] = -dicID
               } else {
                   insertTree(rootChar: nextRootChar, rootIdx: t)
               }
           } else {
               insertTree(rootChar: nextRootChar, rootIdx: t)
           }
       }

   }

   /// Create Key Value to find word.
   ///
   /// - Parameter sortedList: <#sortedList description#>
   /// - Returns: <#return value description#>
   private func createDicKV(sortedDicArray: [String]) -> [String: Int] {
       var kv: [String: Int] = [:]
       for (i, k) in sortedDicArray.enumerated() {
           kv[k] = i
       }
       return kv
   }

   /// Get a index of node character
   ///
   /// - Parameter c: <#c description#>
   /// - Returns: <#return value description#>
   private func getCode(_ c: Character) -> Int? {
       return self.codeTableCtoI[c]
   }

   /// Search word from double array tree.
   ///
   /// - Parameter word: The target word.
   /// - Returns: Array of matched words.
   public func search(word: String) -> [String] {
       let terminate_char_id = self.codeTableCtoI[self.TERMINATE_CHAR]!
       var results: [String] = []

       func traverse(char: Character, s: Int) -> Int {
           guard let code = self.getCode(char) else {
               return -1 // end of search. couldn't find target word from dic.
           }

           let t = self.base[s] + code
           
           if self.check[t] == s {
               let nextT = self.base[t] + terminate_char_id
               
               if self.base[t] < 0 {
                   return -1
               }
               
               if t == check[nextT] {
                   results.append(self.dic[-base[nextT]])
               }

               return t
           }

           return -1 // end of search. couldn't find target word from dic.
       }

       var n = self.ROOT_IDX
       for c in word {
           n = traverse(char: c, s: n)
           if n < 0 {
               break
           }
       }

       return results
   }

}

 しかし、自分で実装してみるとダブル配列を構築するところが、想定していた以上に難しく、何度か書き直して、複雑なパターンでテストしたり、実装を修正したりを繰り返していました。
 辞書構築には、readDicメソッド、buildDicメソッドを順に読んでダブル配列による処理結果を構築します。(doubleArray.defが出力されたファイルとして得られる。)

let da = DoubleArray()
da.readDic(/path/to/unidic.def)
da.buildDic(/path/to/doubleArray.def)

 この時点で、loadData・searchメソッドを使用してダブル配列の動作の確認を行えるようになりました。こちらは、ダブル配列の構築した結果を使用するだけなので、とても簡単に実装できました。searchメソッドに引数として処理したい文字列を渡すと、その候補を出力してくれるようになります。searchメソッドは、forとかで実装した方が良かったんでしょうが、実装しやすさから再帰で実装しています。開発しているときは、searchメソッドの方から先に実装して、動作のイメージをつかむようにしました。その時は、ダブル配列の構築によりできるデータを擬似データとして人手で作成していました。

da.loadData(/path/to/doubleArray.def)
da.search(word: "ぬぎ捨てりゃね")) // ぬぎ捨てりゃ、ぬぎ
da.search(word: "すば抜けよふあ")) //  "すば抜け", "すば抜けよふ"​

学習データ

 形態素解析器で形態素に分割する際に、コストを計算するのですが、その際のコストには、連接コストと生起コストを使用します。連接コストと生起コストは、既に学習済みのコストとして、既存の辞書データと一緒に公開されているのですが、今回はその部分も学習させて、コストを用意してみました。
 まず、そのためには教師データを用意しないといけません。タグ付きのデータなど、十分な量のデータを探したのですが、自分の手の届く範囲では、形態素解析に適用可能なプロによるアノテーションが行われたデータを見つけることができませんでした。そのため、今回は野良教師データを作成することにしました。そのために、Wikipediaや青空文庫のデータに対して、既存の形態素解析器による品詞タグ付けを行いました。この部分は、別にSwiftでやる必要はなかったのですが、ここまできたらこの部分もなんとかSwiftで実装しようと思い、Swiftで実装することにしました。その時点で、Swiftで既存の形態素解析器を呼ぶ方法として考えたのが以下の手法です。

・Githubで公開されているMeCabのSwiftのラッパー実装を使う
→ Swift 5.0を使用したかったため、Swift 4.0までしか対応していないmecab-swiftというラッパーは、動作させることができませんでした…やり方があるのかもしれませんが、その当時はわかりませんでした。

・自作でMeCabのSwiftラッパーを作る
→ これは今回の道とは大きくズレる新しいプロジェクトになってしまいそうでしたので、辞めました。自分の形態素解析器の完成を優先させたかったです。

・SwiftからC++経由でMeCabを読ぶ
→ Swiftからは直接C++を呼べないようなので、Objective-Cのラッパー関数経由で呼ぶようです。これはできそう!と思ったのですが、もっと簡易な手法があったので、そちらを採用することにしました。

・SwiftからPythonを呼びMeCabを使用する
→ SwiftにPythonのコードをそのまま記述することができるので、最終的にこの手法でMeCabを呼び出しました。

 まずは、pyenvなどで構築した環境に、pipでMeCabなどの既存の形態素解析器をインストールし、通常の形態素解析ができる状態にします。今回は、UniDicを辞書として使用しているため、この教師データもUniDicを使用する必要があります。(実は、辞書の設定を忘れてて、コスト計算まで行って、結果がおかしいことに気づいたときに、このミスに気が付きました。)
 この教師データ作り、1文ごとに形態素解析するんですが、Wikipediaなどの膨大な量のデータを処理するので、さすがに逐次処理させていたら時間がかかります。そのため、この部分は並列で処理できるようにしました。幸いSwiftにはDispatchQueueという仕組みが用意されているので、難しい並列の処理も簡単に書くことができます。

ラティス構築

 最小コスト法によるコスト計算を行う上で、ラティスという構造を作成します。ラティスを構築して、逆引きでヴィタビアルゴリズムで最小コストのパスを取得すると、その形態素の組み合わせが形態素解析の結果となっているわけです。名前にasyncってつけてるのは、学習のときに逐次処理でグラフを構築して1つずつデータを処理していたら、とても現実的な時間じゃ学習が終わらないと判断したためです。今回は、GPUなどは使うような実装ではないので、CPUでなるべく処理を終わらせるために、そうしました。

    private func buildGraphAsync(_ sentence: String, isTraining: Bool = false) -> Graph {
       let length = sentence.count
       var input = sentence

       // create empty graph array. length + 2(spaces for BOS and EOS).
       var graph = Graph(repeating: [], count: length + 2)
       let cands = CharType.group(sentence)

       let bosNode = createNode(surface: "BOS", position: POSITION.BOS, pos: POS.BOS)
       graph[POSITION.BOS] = [bosNode]

       for i in (0..<length) {
           var matchedSurfaces = self.da.search(word: input)
           var singleWord = true

           if let cand = cands[i] {
               if !matchedSurfaces.contains(cand) {
                   matchedSurfaces.append(cand)
               }
           }

           for surface in matchedSurfaces {
               let surfaceLength = surface.count

               if surfaceLength == 1 {
                   singleWord = false
               }

               if let ps = self.features[surface] {
                   for pos in ps {
                       let newNode = createNode(surface: surface,
                           position: i + surfaceLength,
                           prevNodes: graph[i])

                       newNode.pos = pos
                       newNode.looCost = self.looCost[NodeLoo(surface: surface, pos: pos), default: 0]
                       if isTraining {
                           newNode.looCost += 1
                       }
                       (newNode.minCost, newNode.minNode) = calcCost(node: newNode)
                       graph[i + surfaceLength].append(newNode)
                   }
               } else {
                   ////// unknown not single word.
                   let newNode = createNode(surface: surface,
                       position: i + surfaceLength,
                       pos: POS.UNK,
                       prevNodes: graph[i]
                   )
                   newNode.looCost = self.looCost[NodeLoo(surface: surface, pos: POS.UNK), default: 0]
                   if isTraining {
                       newNode.looCost += 1
                   }
                   (newNode.minCost, newNode.minNode) = calcCost(node: newNode)
                   graph[i + surfaceLength].append(newNode)
               }
           }

           ///////// unknown single word.
           if singleWord { // insert dummy node. TODO: unknown word proc.
               let oneSurface = String(input.prefix(1))
               let newNode = createNode(surface: oneSurface,
                   position: i + 1,
                   pos: POS.UNK,
                   prevNodes: graph[i]
               )
               newNode.looCost = self.looCost[NodeLoo(surface: oneSurface, pos: POS.UNK), default: 0]
               if isTraining {
                   newNode.looCost += 1
               }
               (newNode.minCost, newNode.minNode) = calcCost(node: newNode)
               graph[i + 1].append(newNode)
           }

           input = String(input.dropFirst(1))
       }

       let eosNode = createNode(surface: "EOS",
           position: length + 1,
           pos: POS.EOS,
           prevNodes: graph[graph.count - 2])

       (eosNode.minCost, eosNode.minNode) = calcCost(node: eosNode)
       graph[graph.count - 1] = [eosNode]

       return graph
   }

 ヴィタヴィ自体は、とても簡単でラティスを構築したら、逆引きでたどるだけです。

private func viterbiAsync(_ graph: Graph, _ gold: (surface: [String], pos: [String])? = nil) -> (best: [Node]?, edge: [NodeEdge: Int]?, loo: [NodeLoo: Int]?) {
       var node = graph.last![0]
       var bestPath: [Node] = [node]

       var localEdgeCost: [NodeEdge: Int] = [:]
       var localLooCost: [NodeLoo: Int] = [:]

       for _ in (0...graph.count) { // +1 for BOS, EOS
           if node.pos! == POS.BOS {
               break
           }
           bestPath.append(node.minNode!)

           if gold != nil {
               if node.pos! != POS.EOS {
                   let looKey = NodeLoo(surface: node.surface, pos: node.pos!)
                   localLooCost[looKey, default: 0] += 1
               }

               let edgeKey = NodeEdge(l: node.minNode!.pos!, r: node.pos!)
               localEdgeCost[edgeKey, default: 0] += 1
           }

           node = node.minNode!
       }

       if gold != nil { // gold path
           let ss = Array(gold!.surface.reversed())
           let ps = Array(gold!.pos.reversed())
           for (i, (s, p)) in zip(ss, ps).enumerated() {
               let prevSurface = ss[i + 1]
               let prevPos = ps[i + 1]
               let edgeKey = NodeEdge(l: prevPos, r: p)
               localEdgeCost[edgeKey, default: 0] -= 1

               let looKey = NodeLoo(surface: s, pos: p)
               if s != "BOS" && s != "EOS" {
                   localLooCost[looKey, default: 0] -= 1
               }

               if prevSurface == "BOS" {
                   break
               }
           }

           return (nil, localEdgeCost, localLooCost)
       }
     return (bestPath.reversed(), nil, nil)
   }​

コスト計算

 最小コスト法のラティスでコスト計算をするのに必要なコストは、連接コストと生起コストなので、そちらのコスト計算をWikipediaから作成した教師データをもとに学習させていきます。学習方法は、構造化学習(実際は構造化SVM)という手法を使用しました。CRFでも良かったのですが、それだとCRF単体の実装や、すでにMeCabなどの形態素解析器と同様の手法になってしまうので、別の手法に挑戦してみたいという単純な理由と、CRFじゃなくても、それっぽい動作する形態素解析器って作れるの?という疑問もあったからです。実装にあたり、参考になる情報としては、工藤さんの本や徳永さんの本の説明が大変詳しく、徳永さんの本では、rubyっぽい擬似コードでも説明されているので、そちらを是非読んでみてください。穴が空くほど読みました…。

public func trainAsync(_ filePath: String, edgeCostPath: String, looCostPath: String, curEpoch: Int = 0) {
       logger.info("start training by svm")
       let data = self.load(filePath)
       let (xTrain, yTrain, xTest, yTest) = self.splitTrainTest(data)
       SSVM.lattice!.setDataset(xTrain: xTrain, yTrain: yTrain, xTest: xTest, yTest: yTest)

       logger.info("========== epoch Before train: \(self.eval(xTest, yTest)) ==========")
       for i in curEpoch ..< self.epoch {
           let startDate = Date()
           SSVM.lattice!.updateCosts()

           if i == 0 || (i + 1) % self.evalBy == 0 {
               logger.info("========== epoch \(i + 1) ===============")
               self.eval(xTest, yTest)
               SSVM.lattice!.saveCost(edgePath: edgeCostPath, looPath: looCostPath)
           }
           self.proClock(startDate)
       }
   }

形態素予測

 ラティスを構築した時点で、どのパスが最小コストかわかっているので、そのパスをヴィタヴィアルゴリズムで計算するだけです。実際にいくつかの文で形態素解析してみました。

明日の天気は晴れです
#### 自作形態素解析器 ####
(surface: ["BOS", "明日", "の", "天気", "は", "晴れ", "です", "EOS"], pos: ["BOS", "名詞-普通名詞-副詞可能", "助詞-格助詞-*", "名詞-普通名詞-一般", "助詞-係助詞-*", "名詞-普通名詞-一般", "助動詞-*-*", "EOS"])

#### MeCab (unidic) ####
明日 アス アス 明日 名詞-普通名詞-副詞可能
の ノ ノ の 助詞-格助詞
天気 テンキ テンキ 天気 名詞-普通名詞-一般
は ワ ハ は 助詞-係助詞
晴れ ハレ ハレ 晴れ 名詞-普通名詞-一般
です デス デス です 助動詞 助動詞-デス 終止形-一般
EOS

#### MeCab (neologd) ####
明日の天気 名詞,固有名詞,一般,*,*,*,明日の天気,アシタノテンキ,アシタノテンキ
は 助詞,係助詞,*,*,*,*,は,ハ,ワ
晴れ 名詞,一般,*,*,*,*,晴れ,ハレ,ハレ
です 助動詞,*,*,*,特殊・デス,基本形,です,デス,デス
EOS

 ↑は、既存の形態素解析器と近い解析結果となりました。「明日の天気」までが固有名詞として登録されているあたり、さすがneologdという感じです。

東京都に住む。
#### 自作形態素解析器 ####
(surface: ["BOS", "東京都", "に", "住む", "。", "EOS"], pos: ["BOS", "UNK", "助詞-接続助詞-*", "動詞-一般-*", "補助記号-句点-*", "EOS"])

#### MeCab (unidic) ####
東京 トーキョー トウキョウ トウキョウ 名詞-固有名詞-地名-一般
都 ト ト 都 名詞-普通名詞-一般
に ニ ニ に 助詞-格助詞
住む スム スム 住む 動詞-一般 五段-マ行 終止形-一般
。 。 補助記号-句点
EOS

#### MeCab (neologd) ####
東京都 名詞,固有名詞,地域,一般,*,*,東京都,トウキョウト,トーキョート
に 助詞,格助詞,一般,*,*,*,に,ニ,ニ
住む 動詞,自立,*,*,五段・マ行,基本形,住む,スム,スム
。 記号,句点,*,*,*,*,。,。,。
EOS

 ↑で、自作形態素解析器では、「東京都」がUNKになっているのは、そもそも使った辞書に「東京都」というのがないんですね…。MeCabのUnidicを使った処理でも、「東京」「都」となっているのはそのためですね。neologdには「東京都」として登録されている?ため、「東京都」として連結した形で処理されています。このあたり、実際にコスト計算とかをやって、動かしてみると未知語処理がいかに重要かがわかりました。おそらく既存の形態素解析器も結構な数を「名詞-一般」とかで出力しているのでは…。今回は、辞書にはない形(東京と都が分割されず、東京都として出力された)が得られたので、辞書に依存していない動作をかくにん!よかった。

すもももももももももうち
#### 自作形態素解析器 ####
(surface: ["BOS", "す", "も", "も", "も", "も", "も", "も", "も", "も", "も", "うち", "EOS"], pos: ["BOS", "形容詞-一般-*", "助詞-係助詞-*", "助詞-係助詞-*", "助詞-係助詞-*", "助詞-係助詞-*", "助詞-係助詞-*", "助詞-係助詞-*", "助詞-係助詞-*", "助詞-係助詞-*", "助詞-係助詞-*", "名詞-普通名詞-副詞可能", "EOS"])

#### MeCab (unidic ) ####
すもも スモモ スモモ 李 名詞-普通名詞-一般
も モ モ も 助詞-係助詞
もも モモ モモ 桃 名詞-普通名詞-一般
も モ モ も 助詞-係助詞
もも モモ モモ 桃 名詞-普通名詞-一般
も モ モ も 助詞-係助詞
うち ウチ ウチ 内 名詞-普通名詞-副詞可能
EOS

#### MeCab (neologd) ####
すもももももも 名詞,固有名詞,一般,*,*,*,すもももももも,スモモモモモモ,スモモモモモモ
も 助詞,係助詞,*,*,*,*,も,モ,モ
ももうち 名詞,固有名詞,一般,*,*,*,桃内,モモウチ,モモウチ
EOS

 形態素解析器での動作確認で必ずといっていいほど出てくる例文。これは惨敗な感じでした…。さすが、既存の形態素解析器という感じですね。

形態素解析に失敗した
#### 自作形態素解析器 ####
(surface: ["BOS", "形態素解析", "に", "失敗", "し", "た", "EOS"], pos: ["BOS", "UNK", "助詞-接続助詞-*", "名詞-普通名詞-サ変可能", "助詞-副助詞-*", "助動詞-*-*", "EOS"])

#### MeCab (unidic) ####
形態 ケータイ ケイタイ 形態 名詞-普通名詞-一般
素 ソ ソ 素 接尾辞-名詞的-一般
解析 カイセキ カイセキ 解析 名詞-普通名詞-サ変可能
に ニ ニ に 助詞-格助詞
失敗 シッパイ シッパイ 失敗 名詞-普通名詞-サ変可能
し シ スル 為る 動詞-非自立可能 サ行変格 連用形-一般
た タ タ た 助動詞 助動詞-タ 終止形-一般
EOS

#### MeCab (neologd) ####
形態素解析 名詞,固有名詞,一般,*,*,*,形態素解析,ケイタイソカイセキ,ケイタイソカイセキ
に 助詞,格助詞,一般,*,*,*,に,ニ,ニ
失敗 名詞,サ変接続,*,*,*,*,失敗,シッパイ,シッパイ
し 動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た 助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
EOS

 この例でもやはり、未知語処理の不足からチャンキングはうまくいっているように見えますが、品詞推定まで含めると、まだまだ難しいところ。(そもそも言語学として、そのへんは難しいところなのでは…)

まとめ

 Swiftを習得するという目的のために、形態素解析器を自作してみました。思った以上に手こずる場面があり、途中からSwiftを習得するという以上に形態素解析器を作り切るという方向に目的がシフトしていましたが、なんとか作り切ることができ、またSwiftのいろんな面を知ることができました。GoogleがSwiftでTensorFlowを記述できるようにしようとしているのもわかるぐらい、とても機能満載な言語で、お気に入りの言語となりました。MLでもSwiftがどんどん使えるようになってくれると嬉しい限りです。
 ここまでの情報が役に立った!っていう人は、以下から寄付できるようになっています。(完全に寄付のつもりでお願いします。)
 ここまで記載した以上のコンテンツはありません。1つだけ追加情報として、コスト計算のときに実は、本やWeb上で公開されている情報だけでは、うまくいかず、あることをやらないと、↑で公開しているような形態素解析の結果が得られなかったので、その点について記載しています。

ここから先は

1,013字

¥ 2,020

この記事が気に入ったらサポートをしてみませんか?