【powershell】 『吾輩は猫である』の表記ゆれを可視化してみる


テキストデータに現れる「取り調べ」と「取調べ」、「噛み砕く」と「かみ砕く」、「～し得る」と「～しうる」「～しえる」etc …… の **表記ゆれ** を形態素解析で可視化してみました。

# 分析してみる
解析対象のテキストデータは [青空文庫](https://www.aozora.gr.jp/index.html) から取得して `《ルビ》` や `［＃注釈］` のほか `｜` （ルビ始点）などを処理しておきます。

本文全体を処理すると時間がかかるので、まずは先頭10段落のみ処理（`Get-JapaneseOrthographicVariants` のコードは後述）。

```
PS > cat .\wagahaiwa_nekodearu.txt | select -first 10 | Get-JapaneseOrthographicVariants
```
![201910232223148.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501098/6100d614-9d52-b5b7-eaa6-396efdc9c2b5.png)


この時点で結構な数の表記ゆれが見つかります。文脈情報は考慮せず、品詞と読みが同じものを集計しているので「火」と「日」などはご愛嬌。
以降は全文解析した結果を変数 `$out` に格納して見ていきます。『吾輩は猫である』レベルとなると相当な時間を要してしまうのが難点です。

まずは2パターン以上ある表記。

```
PS > $out | where count -gt 2 | sort count
```
![201910232224354.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501098/9f4e2682-2745-51e8-6b23-afaaab05cb33.png)


どうしても名詞が多くなってしまうので動詞に絞りこみます。

```
PS > $out | where count -gt 2 | where type -match ^動詞
```
![201910232224555.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501098/ee2b25c2-1904-4704-46e2-64079428a238.png)

圧倒的に「云う」が使われていますが「言う」も無視できない回数あるようです。校正の方がいれば間違いなく指摘されるでしょう。
（2019-10-27 追記：現在は常用漢字外ですが、当初は自分の言葉は「言う」、他人の言葉の引用は「云う」という使い分けで適切だったようです）

次に副詞。「ことごとく」がだいぶバラついていますね。しかし表記ゆれとして検出されたのがこの2件のみというのはやはり文豪の語彙力。
![201910232225051.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501098/6577cb3e-9922-1457-b9f6-d98a58587a6e.png)


形容詞は「軟かい」とか「少い」が気になりますね。「佳い」は検索しても見つからなかったので何かの漢語が引っかかったようです。
![201910232225143.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501098/cee4929f-cd63-04c3-570a-fd749d91fd8b.png)

# コード
## 下準備： NMeCab 読み込みなど
[NMeCab](https://ja.osdn.net/projects/nmecab/) を使用します。
公式サイトから zip をダウンロードして任意のフォルダに解凍します（この例では `C:\tools` ）。
ひらがなのカタカナ化なども行うのでこの段階で型を `Add-Type` しておきましょう。

``` powershell
$settingPath = "C:\tools"
try {
    Add-Type -Path ("{0}\NMeCab*\bin\LibNMeCab.dll" -f $settingPath)
    $MECABPARAM = New-Object NMeCab.MeCabParam
    $MECABPARAM.DicDir = Resolve-Path ("{0}\NMeCab*\dic\ipadic" -f $settingPath)
    $MECABTOKENIZER = [NMeCab.MeCabTagger]::Create($MECABPARAM)
}
catch {
    Write-Host "download and unzip NMecab on " -NoNewline -ForegroundColor Magenta
    Write-Host $settingPath -NoNewline -ForegroundColor White
    Write-Host " !" -ForegroundColor Magenta
}

Add-Type -AssemblyName Microsoft.VisualBasic
```

## 下準備：動詞の終止形の読み取得
作成した `$MECABTOKENIZER` の `ParseToNode` を使うと形態素解析の結果が取得できます。 `.Next` プロパティを追うことで解析単位をそれぞれ見ていくことができます。 ネットワークのノードをたどるようなイメージです。
最初は `stat` が `BOS` 、最後は `EOS` になるのでスキップします。Beginning (End) Of Sentence の略ですかね。各ノードにはいろいろなプロパティがぶら下がっていますが、実際に使うのは `surface` （表層系）と `feature` くらいです。ちなみに各情報を確認する場合は以下のような形にします。

```powershell
$parsed = $MECABTOKENIZER.ParseToNode("庭には2羽鶏がいる")
while ($parsed.stat -ne "EOS") {
    if ($parsed.stat -eq "BOS") {
        $parsed = $parsed.Next
        continue
    }
    $parsed.surface
    $parsed.feature
    $parsed = $parsed.Next
}

# 庭
# 名詞,一般,*,*,*,*,庭,ニワ,ニワ
# に
# 助詞,格助詞,一般,*,*,*,に,ニ,ニ
# は
# 助詞,係助詞,*,*,*,*,は,ハ,ワ
# 2
# 名詞,数,*,*,*,*,*
# 羽
# 名詞,接尾,助数詞,*,*,*,羽,ワ,ワ
# 鶏
# 名詞,一般,*,*,*,*,鶏,ニワトリ,ニワトリ
# が
# 助詞,格助詞,一般,*,*,*,が,ガ,ガ
# いる
# 動詞,自立,*,*,一段,基本形,いる,イル,イル
```

各ノードの `feature` を解析していけばいいのですが、動詞に関しては **終止形の読みが直接取得できない** という問題があります。
たとえば「言いたい」を解析すると `動詞,自立,*,*,五段・ワ行促音便,連用形,言う,イイ,イイ` となり、最終的に必要な「イウ」という情報をここから導き出さなくてはいけません。
基本形の「言う」を再度 `ParseToNode` に投入するという方法もあるのですが、そうすると文脈情報が消えてしまうため、たとえば「やって来る」のような複合動詞が「ヤルクル」になってしまうなどの問題が生じます。
最終的に国語の教科書を引っ張り出して活用表とにらめっこしながら力技で条件分岐を書きました。
文語表現に対応できていないほかは漏れはないはず… ですが不備など見つかりましたらご指摘願います。

<details><summary>長くなったので折りたたみ</summary><div>

```powershell
function Get-BasetypeYomi {
    <#
        .SYNOPSIS
        終止形の読み仮名に変換する
        .PARAMETER pos
        品詞名（ PartOfSpeech ）
        .PARAMETER yomi
        読み（カタカナ）
        .PARAMETER cType
        活用型（ conjugation type ）
        .PARAMETER cForm
        活用形（ conjugation form ）
    #>
    param (
        [string]$pos,
        [string]$yomi,
        [string]$cType,
        [string]$cForm
    )
    if ($pos -match "^形容詞") {
        if ($cForm -match "^(基本|連体)") {
            return $yomi
        }
        if ($cForm -match "^連用") {
            return ($yomi -replace "(カッ|ク|ウ)$", "イ")
        }
        if ($cForm -eq "ガル接続") {
            return ($yomi + "イ")
        }
        if ($cForm -eq "体言接続") {
            return ($yomi -replace "キ$", "イ")
        }
        return ($yomi -replace "..$", "イ")
    }
    if ($pos -match "^動詞") {
        if ($cForm -match "^(基本|連体)") {
            return $yomi
        }
        if ($cType -match "^カ変") {
            if ($yomi -match "コヨ$") {
                return ($yomi -replace "コヨ$", "クル")
            }
            if ($cForm -match "^(仮定|命令)") {
                return ($yomi -replace "..$", "クル")
            }
            return ($yomi -replace ".$", "クル")
        }
        if ($cType -match "^サ変") {
            if ($yomi -match "シヨ$") {
                return ($yomi -replace "シヨ$", "スル")
            }
            if ($cForm -match "^(仮定|命令)") {
                if ($yomi -match "[ジズゼ][レロヨ]$") {
                    return ($yomi -replace "..$", "ズル")
                }
                return ($yomi -replace "..$", "スル")
            }
            return ($yomi -replace ".$", "スル")
        }
        if ($cType -match "^一段") {
            if ($cForm -in @("未然形", "連用形")) {
                return ($yomi + "ル")
            }
            if ($cForm -match "^(命令|仮定)") {
                return ($yomi -replace ".$", "ル")
            }
            return $yomi
        }
        else {
            $tail = switch -regex ($cType) {
                "^五段・ア行" {"ウ"}
                "^五段・カ行" {"ク"}
                "^五段・ガ行" {"グ"}
                "^五段・サ行" {"ス"}
                "^五段・タ行" {"ツ"}
                "^五段・ナ行" {"ヌ"}
                "^五段・ハ行" {"フ"}
                "^五段・バ行" {"ブ"}
                "^五段・マ行" {"ム"}
                "^五段・ラ行" {"ル"}
                "^五段・ワ行" {"ウ"}
                Default {""}
            }
        }
        return ($yomi -replace ".$", $tail)
    }
}
```
</div></details>

## 下準備： node の変換
上記の内容を使って、形態素解析結果の各 node を処理しやすい `（基本形）TAB（読み）／（品詞）` 形式の配列としてフォーマットします。

```powershell
function Convert-NMeCabNode2Info ($node) {
    <#
        .SYNOPSIS
        NMeCab 出力の node を「（基本形）TAB（読み）／（品詞）」形式に変換する
        ・記号や助詞、助動詞は無視する
    #>
    $ret = New-Object System.Collections.ArrayList
    while ($node.stat -ne "EOS") {
        if ($node.stat -eq "BOS") {
            $node = $node.Next
            continue
        }
        $feature = @($node.feature -split ",")
        $partOfSpeech = "{0}-{1}" -f $feature[0], $feature[1] # 品詞
        $surface = $node.surface # 表層形
        $basetype = $feature[6] # 基本形
        if ($basetype -eq "*") {
            $basetype = $surface
        }
        if ($partOfSpeech -notmatch "^(記号|助)") {
            $yomi = $feature[7]
            if ($partOfSpeech -match "^(動詞|形容詞)") {
                $yomi = Get-BasetypeYomi -pos $partOfSpeech -yomi $yomi -cType $feature[4] -cForm $feature[5]
            }
            if ((-not $yomi) -or ($yomi -eq "*")) {
                $yomi = [Microsoft.VisualBasic.Strings]::StrConv($surface, [Microsoft.VisualBasic.VbStrConv]::Katakana)
            }
            $nodeInfo = "{0}`t{1}／{2}" -f $basetype, $yomi, $partOfSpeech
            $ret.Add($nodeInfo) > $null
        }
        $node = $node.Next
    }
    return $ret
}
```

## 頻度を集計
最終的に、 `Goup-Object` で読みと品詞が同じものを集計して、2通り以上に表記ゆれしているものを表示します。

```powershell
function Get-JapaneseOrthographicVariants {
    <#
        .SYNOPSIS
        表記ゆれを取得する
        ・パイプライン経由での入力のみ受け付ける
    #>

    $array = New-Object System.Collections.ArrayList
    foreach ($line in $input) {
        $node = $MECABTOKENIZER.ParseToNode($line)
        $nodeInfo = Convert-NMeCabNode2Info -node $node
        foreach ($n in $nodeInfo) {
            $array.Add($n) > $null
        }
    }

    $result = New-Object System.Collections.ArrayList
    $yomiObject = $array | ConvertFrom-Csv -Delimiter "`t" -Header "word", "metaInfo"
    $group = $yomiObject | Group-Object "metaInfo"
    foreach ($g in $group) {
        $variation = @($g.Group.word | Sort-Object -Unique)
        if ($variation.Count -eq 1) {
            continue
        }
        $degree = @()
        $g.Group.word | Group-Object | ForEach-Object {
            $degree += ("{0}({1})" -f $_.Name, $_.Count)
        }
        $infos = @($g.Name -split "／")
        $record = "{0}`t{1}`t{2}`t{3}" -f $infos[0], $infos[1], ($degree -join ", "), $variation.Count
        $result.Add($record) > $null
    }
    $output = $result | Sort-Object -CaseSensitive | ConvertFrom-Csv -Delimiter "`t" -Header "yomi", "type", "variants", "count"
    $Global:LAST_ANALYSED_VARIANTS = $output
    return $output
}
```

+ 配列に要素を追加する処理を行う場合、 `@()` で作成したものに `+=` するよりは `System.Collection.Arraylist` に `.Add()` したほうが高速という話を見かけました。今回のケースでも、解析対象が増えるほど差が顕著になるような感触です。
+ カスタムオブジェクトを作成する際、 文字列配列にしてから `ConvertFrom-CSV` する方法が楽なので多用しています。。
+ 実行時に自動的に `$LAST_ANALYSED_VARIANTS` というグローバル変数に結果を格納しています。変数への格納を忘れてしまってもこの変数を呼び出せば直近の解析結果を見られます。


## 全体
<details><summary>長くなったので折りたたみ</summary><div>

```powershell

<# ==============================

cmdlets for processing japanese with NMeCab

============================== #>


$settingPath = "C:\tools"
try {
    Add-Type -Path ("{0}\NMeCab*\bin\LibNMeCab.dll" -f $settingPath)
    $MECABPARAM = New-Object NMeCab.MeCabParam
    $MECABPARAM.DicDir = Resolve-Path ("{0}\NMeCab*\dic\ipadic" -f $settingPath)
    $MECABTOKENIZER = [NMeCab.MeCabTagger]::Create($MECABPARAM)
}
catch {
    Write-Host "download and unzip NMecab on " -NoNewline -ForegroundColor Magenta
    Write-Host $settingPath -NoNewline -ForegroundColor White
    Write-Host " !" -ForegroundColor Magenta
}
Add-Type -AssemblyName Microsoft.VisualBasic

function Get-BasetypeYomi {
    <#
        .SYNOPSIS
        終止形の読み仮名に変換する
        .PARAMETER pos
        品詞名（ PartOfSpeech ）
        .PARAMETER yomi
        読み（カタカナ）
        .PARAMETER cType
        活用型（ conjugation type ）
        .PARAMETER cForm
        活用形（ conjugation form ）
    #>
    param (
        [string]$pos,
        [string]$yomi,
        [string]$cType,
        [string]$cForm
    )
    if ($pos -match "^形容詞") {
        if ($cForm -match "^(基本|連体)") {
            return $yomi
        }
        if ($cForm -match "^連用") {
            return ($yomi -replace "(カッ|ク|ウ)$", "イ")
        }
        if ($cForm -eq "ガル接続") {
            return ($yomi + "イ")
        }
        if ($cForm -eq "体言接続") {
            return ($yomi -replace "キ$", "イ")
        }
        return ($yomi -replace "..$", "イ")
    }
    if ($pos -match "^動詞") {
        if ($cForm -match "^(基本|連体)") {
            return $yomi
        }
        if ($cType -match "^カ変") {
            if ($yomi -match "コヨ$") {
                return ($yomi -replace "コヨ$", "クル")
            }
            if ($cForm -match "^(仮定|命令)") {
                return ($yomi -replace "..$", "クル")
            }
            return ($yomi -replace ".$", "クル")
        }
        if ($cType -match "^サ変") {
            if ($yomi -match "シヨ$") {
                return ($yomi -replace "シヨ$", "スル")
            }
            if ($cForm -match "^(仮定|命令)") {
                if ($yomi -match "[ジズゼ][レロヨ]$") {
                    return ($yomi -replace "..$", "ズル")
                }
                return ($yomi -replace "..$", "スル")
            }
            return ($yomi -replace ".$", "スル")
        }
        if ($cType -match "^一段") {
            if ($cForm -in @("未然形", "連用形")) {
                return ($yomi + "ル")
            }
            if ($cForm -match "^(命令|仮定)") {
                return ($yomi -replace ".$", "ル")
            }
            return $yomi
        }
        else {
            $tail = switch -regex ($cType) {
                "^五段・ア行" {"ウ"}
                "^五段・カ行" {"ク"}
                "^五段・ガ行" {"グ"}
                "^五段・サ行" {"ス"}
                "^五段・タ行" {"ツ"}
                "^五段・ナ行" {"ヌ"}
                "^五段・ハ行" {"フ"}
                "^五段・バ行" {"ブ"}
                "^五段・マ行" {"ム"}
                "^五段・ラ行" {"ル"}
                "^五段・ワ行" {"ウ"}
                Default {""}
            }
        }
        return ($yomi -replace ".$", $tail)
    }
}

function Convert-NMeCabNode2Info ($node) {
    <#
        .SYNOPSIS
        NMeCab 出力の node を「（基本形）TAB（読み）／（品詞）」形式に変換する
        ・記号や助詞、助動詞は無視する
    #>
    $ret = New-Object System.Collections.ArrayList
    while ($node.stat -ne "EOS") {
        if ($node.stat -eq "BOS") {
            $node = $node.Next
            continue
        }
        $feature = @($node.feature -split ",")
        $partOfSpeech = "{0}-{1}" -f $feature[0], $feature[1] # 品詞
        $surface = $node.surface # 表層形
        $basetype = $feature[6] # 基本形
        if ($basetype -eq "*") {
            $basetype = $surface
        }
        if ($partOfSpeech -notmatch "^(記号|助)") {
            $yomi = $feature[7]
            if ($partOfSpeech -match "^(動詞|形容詞)") {
                $yomi = Get-BasetypeYomi -pos $partOfSpeech -yomi $yomi -cType $feature[4] -cForm $feature[5]
            }
            if ((-not $yomi) -or ($yomi -eq "*")) {
                $yomi = [Microsoft.VisualBasic.Strings]::StrConv($surface, [Microsoft.VisualBasic.VbStrConv]::Katakana)
            }
            $nodeInfo = "{0}`t{1}／{2}" -f $basetype, $yomi, $partOfSpeech
            $ret.Add($nodeInfo) > $null
        }
        $node = $node.Next
    }
    return $ret
}

function Get-JapaneseOrthographicVariants {
    <#
        .SYNOPSIS
        表記ゆれを取得する
        ・パイプライン経由での入力のみ受け付ける
    #>

    $array = New-Object System.Collections.ArrayList
    foreach ($line in $input) {
        $node = $MECABTOKENIZER.ParseToNode($line)
        $nodeInfo = Convert-NMeCabNode2Info -node $node
        foreach ($n in $nodeInfo) {
            $array.Add($n) > $null
        }
    }

    $result = New-Object System.Collections.ArrayList
    $yomiObject = $array | ConvertFrom-Csv -Delimiter "`t" -Header "word", "metaInfo"
    $group = $yomiObject | Group-Object "metaInfo"
    foreach ($g in $group) {
        $variation = @($g.Group.word | Sort-Object -Unique)
        if ($variation.Count -eq 1) {
            continue
        }
        $degree = @()
        $g.Group.word | Group-Object | ForEach-Object {
            $degree += ("{0}({1})" -f $_.Name, $_.Count)
        }
        $infos = @($g.Name -split "／")
        $record = "{0}`t{1}`t{2}`t{3}" -f $infos[0], $infos[1], ($degree -join ", "), $variation.Count
        $result.Add($record) > $null
    }
    $output = $result | Sort-Object -CaseSensitive | ConvertFrom-Csv -Delimiter "`t" -Header "yomi", "type", "variants", "count"
    $Global:LAST_ANALYSED_VARIANTS = $output
    return $output
}

```
</div></details>