白猫のメモ帳

C#とかJavaとかJavaScriptとかHTMLとか機械学習とか。

TF-IDFでニュースを要約する

こんばんは。

猫様に椅子を取られて座るところがありません。

さて、今回はTF-IDFを使って文章要約を試してみます。
要約と謳ってはいますが、重要な文を抜き出すだけなのでご注意を。

TF-IDFってなんだ


今回のポイント、TF-IDFについて簡単に説明します。

TF-IDFは、「TF」と「IDF」という
二つの指標を用いて、文章中の各単語の重要度を表現する値です。

TF(Term Frequency) = 単語の出現頻度

TFは、とある文章の中で、とある単語が何回出現するかという指標です。
たとえば次のような文章を見てみます。

今日は朝から雨が降っている。
わたしは雨はそんなに嫌いではないが、出かけるには少し億劫だ。

もう一つ。

今日は朝から雨が降っている。
こんな日にはちょっと気分を盛り上げるために、朝からひとかけらのチョコレートを頬張るのだ。

1行目が同じ文章で、2行目がそれぞれ違います。
それぞれの例文のテーマは何かというと、「雨」と「朝」が何となくしっくりくるでしょうか。

1つ目の例文には「雨」が2回、2つ目の例文には「朝」が2回でてきます。
たくさん出てくる単語の重要度が高そうというのはとてもシンプルですね。

式としてはこうなります。


 \displaystyle TF = \frac{対象の文における対象の単語の出現回数}{対象の文の全単語数}


ちなみに、名詞だけを抜き出して計算することが多いです。

IDF(Inverse Document Frequency) = 逆文書頻度

IDFは、とある単語が文章全体でどれぐらいレア度が高いかという指標です。

例えば、猫についてのコラムがあったとします。
ここから最後の一文だけを抜き出してみましょう。

猫の目は~うんぬん
猫のしっぽは~うんぬん
猫の爪は~うんぬん
・
・
・
猫の肉球は一日中ぷにぷにしていたくなるが、やりすぎるとすごく怒る。

当然文章全体で見た場合には「猫」の重要度は高いのですが、
「猫」という単語は文章全体のいたるところで登場するため、
最後の文からすれば「肉球」や「ぷにぷに」といった単語の方が文としての重要度は高いわけです。

式としてはこうなります。


 \displaystyle IDF = log \frac{すべての文の数}{ある単語を含む文の数}


ある単語のすべての文における出現回数ではないんですね。

TF-IDF


はい。TFとIDFを掛けます。

・その文の中でよく出てくる単語
・他の文ではあまり出てこない単語

が重要度が高いということですね。

実装する


今回は「重要度の高い単語がたくさん含まれている = 重要な文」という安直な発想のもと、
ニュースの中で重要である3文を抜き出すことにしてみました。

①TF-IDFを計算し、要約対象のニュースで重要な単語を抜き出す
②要約対象のニュースの本文から、重要な単語が多く含まれる文を抜き出す

という二段階の手順です。

要約するニュースと、IDFを計算するための文章はテキストで持っていることにします。
わたしは、Twitter Botが変なことを呟くために貯めているので、DBから拾ってます。
RSSから直接拾いに行ったりするのも楽しいかと思います。

ベイジアンフィルタの回ではBag-of-WordsのためにEclipse Collectionを使ってみましたが、
今回はApache Commonsです。お好みでいいんじゃないでしょうか。

とりあえずPOM。

<repositories>
    <repository>
        <id>Atilika Open Source repository</id>
        <url>http://www.atilika.org/nexus/content/repositories/atilika</url>
    </repository>
    <repository>
        <id>codelibs.org</id>
        <name>CodeLibs Repository</name>
        <url>http://maven.codelibs.org/</url>
    </repository>
</repositories>

<dependencies>
    <dependency>
        <groupId>org.atilika.kuromoji</groupId>
        <artifactId>kuromoji</artifactId>
        <version>0.7.7</version>
        <type>jar</type>
        <scope>compile</scope>
    </dependency>
    <dependency>
        <groupId>org.codelibs</groupId>
        <artifactId>lucene-analyzers-kuromoji-ipadic-neologd</artifactId>
        <version>5.5.0-20160411</version>
    </dependency>
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-lang3</artifactId>
        <version>3.5</version>
    </dependency>
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-collections4</artifactId>
        <version>4.1</version>
    </dependency>
</dependencies>


そしてJava

public class Main {
    
    public static void main(String[] args) throws IOException {
        
        // 全文を取得
        List<List<String>> allSentences = loadSentences("C:\\Users\\shironeko\\Desktop\\all.txt");

        // IDF用にBag-of-Wordsを作成
        Bag<String> idfBag = toBag(allSentences);

        // 要約対象の文を取得
        List<List<String>> targetSentences = loadSentences("C:\\Users\\shironeko\\Desktop\\target.txt");

        // TF用にBag-of-Wordsを作成
        Bag<String> tfBag = toBag(targetSentences);

        // 重要な文を抜出し
        List<String> importantSentences = getImportantSentences(allSentences, idfBag, targetSentences, tfBag);
        
        // 表示
        importantSentences.forEach(System.out::println);
    }
    
    /**
     * テキストを読み込んで分かち書き
     */
    private static List<List<String>> loadSentences(String path) throws IOException {
        
        // データ読み込み
        List<String> lines = Files.readAllLines(Paths.get(path), Charset.forName("Shift-JIS"));

        // 文ごとに形態素解析する
        return Stream.of(lines.stream().collect(Collectors.joining()).split("。"))
                     .map(sentence -> tokenize(sentence.trim()))
                     .collect(Collectors.toList());
    }
    
    /**
     * 文を形態素解析する
     */
    private static List<String> tokenize(String src) {
        
        List<String> ret = new ArrayList<>();
        try (JapaneseTokenizer jt = new JapaneseTokenizer(null, false, JapaneseTokenizer.Mode.NORMAL)) {
            
            CharTermAttribute ct = jt.addAttribute(CharTermAttribute.class);
            
            jt.setReader(new StringReader(src));
            jt.reset();
            
            while (jt.incrementToken()) {
                ret.add(ct.toString());
            }
            
        } catch (IOException e) {
        }
        
        return ret;
    }
    
    /**
     * 形態素解析済み分をBag-of-Wordsに変換
     */
    private static Bag<String> toBag(List<List<String>> list) {
        return list.stream().flatMap(l -> l.stream())
                            .collect(Collectors.toCollection(HashBag::new));
    }
    
    /**
     * 重要度の高い文を抜出し
     */
    private static List<String> getImportantSentences(List<List<String>> allSentences, 
                Bag<String> idfBag, List<List<String>> targetSentences, Bag<String> tfBag) {
        
        // TF-IDFのレートが高い単語を取得
        Map<String, Double> hiRate = tfBag.uniqueSet().stream()
                                        .map(word -> Pair.of(word, getTf(word, tfBag) * getIdf(word, allSentences.size(), idfBag)))
                                        .sorted(Comparator.comparing(p -> p.getRight(), Comparator.reverseOrder()))
                                        .limit(tfBag.size() / 10)
                                        .collect(Collectors.toMap(p -> p.getLeft(), p -> p.getRight()));

        // フィルタ
        List<List<String>> retain = targetSentences.stream()
                                                   .sorted(Comparator.comparing(l -> getPoint(l, hiRate), Comparator.reverseOrder()))
                                                   .limit(3)
                                                   .collect(Collectors.toList());

        // ソート順壊れちゃうので元のリストから表示
        List<List<String>> resList = new ArrayList<>(targetSentences);
        resList.retainAll(retain);
        
        return resList.stream().map(l -> String.join("", l)).collect(Collectors.toList());
    }
    
    /**
     * TFを計算する
     */
    private static double getTf(String word, Bag<String> bag) {
        return (double)bag.getCount(word) / (double)bag.size();
    }
    
    /**
     * IDFを計算する
     */
    private static double getIdf(String word, int size, Bag<String> bag) {
        return Math.log((double)size / (double)(bag.getCount(word) + 1));
    }
    
    /**
     * スコアを計算する
     */
    private static double getPoint(List<String> list, Map<String, Double> hiRate) {
        return list.stream().collect(Collectors.summingDouble(l -> hiRate.getOrDefault(l, 0.0)));
    }
}

試してみる


解析用データの分量はお好みですが、10,000文で試してみることにします。
こんな感じの文が10,000行のテキストファイルです。

その理由は単純で、GoogleはiOSデバイスをコントロールできないからだ。
忘れていた少年時代に、思いをはせる“仕事”をさせてもらった。
衰えることのないガンダム人気に迫った。
可能な限り忠実にボクシングの世界を再現する、それこそがフークア監督の狙いだった。
マンションのように外廊下じゃなくて縦階段でしょ、昭和の。
こういう活動をもっと増やしてほしい。
6回にも先頭・ソーンダースを左線二塁打で出した後、続くスモークの飛球を中堅手エルズベリーが落球。
「ベスト移籍」の一例として、マインツからレスターへ移籍した日本代表FW岡崎慎司を紹介した。
…


ためしにこの記事を要約してみます。
長時間労働に関する記事です。

●◇休憩を取れない社員に残業代支払いを命令 長時間労働の抑制策には、電通のように定刻で一斉消灯する方法や、特定の曜日をノー残業デーとする方法があります
●社員が自分の意思で仕事をするのは自由ですが、仕事をせざるを得ない状況を会社が作り出して、社員が休憩を取れずに働く場合は、残業代の支払いが発生します
●しかし、社員が休憩時間を設定通りに取れているか、持ち帰り残業をしていないか、休日出勤が増えていないかなども併せて確認することが大切です


なんとなく、要約出来ているような気がします…。
記事の中盤は具体的な事例について触れられていますが、うまく避けているようです。


もうひとつこの記事をためしてみます。
フィギュアスケート、グランプリファイナルの結果です。

フィギュアスケートのグランプリ(GP)ファイナルは10日(日本時間11日)、フランス・マルセイユで行われ、男子フリーでは羽生結弦(22=ANA)がフリー187・37点、総合293・90点で男女を通じて史上初となる4連覇を達成した
●SP3位の世界選手権2連覇中のハビエル・フェルナンデス(25=スペイン)が177.01点、合計268・77点で4位
●SP2位のパトリック・チャン(25=カナダ)が166.99点、合計266・75点で5位、アダム・リッポン(27=米国)は149・17点、合計233・10点で6位だった

1つめはよさそうですが、2位と3位の情報がなく、4~6位の情報があるのはちょっとだめかも知れません。

まとめ


ニュースの場合には、

・タイトルとの類似度を比較する
・文の先頭の1行の優先度を高く設定する

などのいろいろなテクニックがあるようです。

また、要約して文字数を減らすには言い回しの変更がとても効果的です。
これはもっと複雑な自然言語処理を行わなければならないですね。


TF-IDFはとてもシンプルな仕組みですが、ある程度の要約(抜き出し)能力は見込めそうです。
何かに使える機会があるといいな。