Help us understand the problem. What is going on with this article?

COTOHA APIで文章を照応解析してファイルに保存する

この記事は株式会社クロノス「~2020年春~勝手にやりますアドベントカレンダー」の3日目の記事になります!

はじめに

最先端をいくエンジニアが使うCOTOHA API(自然言語処理・音声処理APIプラットフォーム)といふものを、私もしてみむとてするなり。
・・・
ってなわけで
面白い記事の多いCOTOHA APIについて私も楽しくて、派手なことしようと思いたち、今回はひたすら地味なことで記事を書きました!!(矛盾)
気持ちとしては、派手なことをする前の下準備としてどうでしょう? っていう内容のつもりです。

知れること・知れないこと

知れること

  • COTOHA APIにおける照応解析の詳しい使い方。
  • COTOHA APIのリファレンスに書いていない、お試しで気づいた挙動
  • APIのレスポンスで返ってきたJsonをクラスで管理する方法

知れないこと

  • Python3.7より以前のバージョンにおける情報
  • スマートなコーディング(途中ゴリゴリ書きました)

照応解析とは何で、今から何がしたいのか

まず「照応解析とは何か」について、公式ページの引用がこちらです。

「そこ」「それ」などの指示詞や「彼」「彼女」などの代名詞、「同〇〇」等の照応詞に対応する先行詞(複数単語からなる先行詞を含む)を抽出し、同一のものとしてまとめて出力するRESTful API です。

ふむふむ、例えば?(さらに引用)

例えば、対話エンジンとユーザとの対話ログの解析において、代名詞を含む文とその前後の文脈から、代名詞が指し示す単語を抽出することで、「彼」や「彼女」などのログ解析にあまり意味のない単語を先行詞に置き換え、より精密なログ解析を実現することが可能です。

つまりは、(これも公式の例文なのですが、)「太郎は友人です。彼は焼き肉を食べた。」という文章を照応解析すると「太郎」と「」は一緒だよーっと結果が返ってくることになります。

ここまで調べて
「派手なことをやる前に照応解析で前処理をやったら、他の自然言語処理の結果も変わってくる(より精度が上がる)のでは?」
となり、タイトルの「COTOHA APIで文章を照応解析してファイルに保存する」をやることとなりました(最初にやろうとしていたことから横道にそれました)
もしかしたら需要あるかもしれない。というのも一因です。

コード

今回は

  • 青空文庫の任意の文章をスクレイピング
  • 文章をCOTOHA APIの照応解析に投げる
  • レスポンスをjson形式からクラスに割り当てる。
  • 一つの照応詞にまるめてからファイルに保存する。

実装を考えます。
例えばさっきの例の「太郎は友人です。彼は焼き肉を食べた。」だと「太郎は友人です。太郎は焼き肉を食べた。」でテキストファイルに保存します。
※ 照応詞とは「太郎」や「彼」のこと

全体的な話

ソース全体はこちら参照
照応解析と関係ない処理も幾分か含まれています。
フォルダ構成はこんな感じです。

├── aozora_scraping.py
├── config.ini
├── cotoha_function.py
├── json_to_obj.py
├── main.py
├── respobj
│   ├── __init__.py
│   └── coreference.py
└── result

__pycache__README.mdは省略しています。resultフォルダ以下に結果のテキストが格納される想定です。

青空文庫の任意の文章をスクレイピング

aozora_scraping.pyの中身
aozora_scraping.py
# -*- coding:utf-8 -*-
import requests
from bs4 import BeautifulSoup

def get_aocora_sentence(aozora_url):
    res = requests.get(aozora_url)
    # BeautifulSoupの初期化
    soup = BeautifulSoup(res.content, 'lxml')
    # 青空文庫のメインテキストの取得
    main_text = soup.find("div", class_="main_text")
    # ルビの排除
    for script in main_text(["rp","rt","h4"]):
        script.decompose()
    sentences = [line.strip() for line in main_text.text.splitlines()]
    # 空の部分の排除
    sentences = [line for line in sentences if line != '']
    return sentences

メソッドget_aocora_sentenceに青空文庫のURLを渡すと、そのページの文章をルビや余白を省いて、改行ごとにリストで返します。

    main_text = soup.find("div", class_="main_text")

なんかは青空文庫の文章の本文が<div class="main_text"></div>で囲まれていると分かった上での処理です。
青空文庫の文章の処理の仕方など以下参考にしました。
COTOHAを利用して、物語の舞台を抽出・図示してみた

文章をCOTOHA APIの照応解析に投げる

cotoha_function.pyの中身
cotoha_function.py
# -*- coding:utf-8 -*-
import os
import urllib.request
import json
import configparser
import codecs

# COTOHA API操作用クラス
class CotohaApi:
    # 初期化
    def __init__(self, client_id, client_secret, developer_api_base_url, access_token_publish_url):
        self.client_id = client_id
        self.client_secret = client_secret
        self.developer_api_base_url = developer_api_base_url
        self.access_token_publish_url = access_token_publish_url
        self.getAccessToken()

    # アクセストークン取得
    def getAccessToken(self):
        # アクセストークン取得URL指定
        url = self.access_token_publish_url

        # ヘッダ指定
        headers={
            "Content-Type": "application/json;charset=UTF-8"
        }

        # リクエストボディ指定
        data = {
            "grantType": "client_credentials",
            "clientId": self.client_id,
            "clientSecret": self.client_secret
        }
        # リクエストボディ指定をJSONにエンコード
        data = json.dumps(data).encode()

        # リクエスト生成
        req = urllib.request.Request(url, data, headers)

        # リクエストを送信し、レスポンスを受信
        res = urllib.request.urlopen(req)

        # レスポンスボディ取得
        res_body = res.read()

        # レスポンスボディをJSONからデコード
        res_body = json.loads(res_body)

        # レスポンスボディからアクセストークンを取得
        self.access_token = res_body["access_token"]

    # 照応解析API
    def coreference(self, document):
        # 照応解析API 取得URL指定
        url = self.developer_api_base_url + "v1/coreference"
        # ヘッダ指定
        headers={
            "Authorization": "Bearer " + self.access_token,
            "Content-Type": "application/json;charset=UTF-8",
        }
        # リクエストボディ指定
        data = {
            "document": document
        }
        # リクエストボディ指定をJSONにエンコード
        data = json.dumps(data).encode()
        # リクエスト生成
        req = urllib.request.Request(url, data, headers)
        # リクエストを送信し、レスポンスを受信
        try:
            res = urllib.request.urlopen(req)
        # リクエストでエラーが発生した場合の処理
        except urllib.request.HTTPError as e:
            # ステータスコードが401 Unauthorizedならアクセストークンを取得し直して再リクエスト
            if e.code == 401:
                print ("get access token")
                self.access_token = getAccessToken(self.client_id, self.client_secret)
                headers["Authorization"] = "Bearer " + self.access_token
                req = urllib.request.Request(url, data, headers)
                res = urllib.request.urlopen(req)
            # 401以外のエラーなら原因を表示
            else:
                print ("<Error> " + e.reason)

        # レスポンスボディ取得
        res_body = res.read()
        # レスポンスボディをJSONからデコード
        res_body = json.loads(res_body)
        # レスポンスボディから解析結果を取得
        return res_body

COTOHA APIを利用する関数については完全に自然言語処理を簡単に扱えると噂のCOTOHA APIをPythonで使ってみたを参考にさせてもらいました。
ただし、照応解析については、urlがbeta/coreferenceからv1/coreferenceに変わっていたのでご注意ください。(今beta版のものもいつか変更される時期がくるんでしょうね、多分)

main.pyで照応解析に文章を渡している前半部分は以下です。ゴリゴリ書きました(説明する部分があるのでそのまま記載)

main.py
# -*- coding:utf-8 -*-
import os
import json
import configparser
import datetime
import codecs
import cotoha_function as cotoha
from aozora_scraping import get_aocora_sentence
from respobj.coreference import Coreference
from json_to_obj import json_to_coreference

if __name__ == '__main__':
    # ソースファイルの場所取得
    APP_ROOT = os.path.dirname(os.path.abspath( __file__)) + "/"

    # 設定値取得
    config = configparser.ConfigParser()
    config.read(APP_ROOT + "config.ini")
    CLIENT_ID = config.get("COTOHA API", "Developer Client id")
    CLIENT_SECRET = config.get("COTOHA API", "Developer Client secret")
    DEVELOPER_API_BASE_URL = config.get("COTOHA API", "Developer API Base URL")
    ACCESS_TOKEN_PUBLISH_URL = config.get("COTOHA API", "Access Token Publish URL")

    # 定数
    max_word = 1800
    max_call_api_count = 150
    max_elements_count = 20
    # 青空文庫のURL
    aozora_html = '任意'
    # 現在時刻
    now_date = datetime.datetime.today().strftime("%Y%m%d%H%M%S")
    # 元のテキストを保存するファイルのパス
    origin_txt_path = './result/origin_' + now_date + '.txt'
    # 結果を保存するファイルのパス
    result_txt_path = './result/converted_' + now_date + '.txt'

    # COTOHA APIインスタンス生成
    cotoha_api = cotoha.CotohaApi(CLIENT_ID, CLIENT_SECRET, DEVELOPER_API_BASE_URL, ACCESS_TOKEN_PUBLISH_URL)

    # 青空文庫のテキストの取得
    sentences = get_aocora_sentence(aozora_html)
    # 比較用に元のテキストを保存
    with open(origin_txt_path, mode='a') as f:
        for sentence in sentences:
            f.write(sentence + '\n')

    # 初期値
    start_index = 0
    end_index = 0
    call_api_count = 1
    temp_sentences = sentences[start_index:end_index]
    elements_count = end_index - start_index
    limit_index = len(sentences)
    result = []
    print("リストの総数" + str(limit_index))
    while(end_index <= limit_index and call_api_count <= max_call_api_count):
        length_sentences = len(''.join(temp_sentences))
        if(length_sentences < max_word and elements_count < max_elements_count and end_index < limit_index):
            end_index += 1
        else:
            if end_index == limit_index:
                input_sentences = sentences[start_index:end_index]
                print('インデックス : ' + str(start_index) + 'から' + str(end_index) + 'まで')
                # 終了条件
                end_index += 1
            else:
                input_sentences = sentences[start_index:end_index - 1]
                print('インデックス : ' + str(start_index) + 'から' + str(end_index-1) + 'まで')
            print(str(call_api_count) + '回目の通信')
            response = cotoha_api.coreference(input_sentences)
            result.append(json_to_coreference(response))
            call_api_count += 1
            start_index = end_index - 1
        temp_sentences = sentences[start_index:end_index]
        elements_count = end_index - start_index

そもそも前提として(当たり前といえば、当たり前かもしれませんが)文章全てを一気に一回のリクエストで送ることはできません。

お試しで発見した挙動

リファレンスでの言及は発見できなかったのですが、

という条件があり、(未検証ですが、他のCOTOHA APIの処理も似たり寄ったりではないかと思います)
while文以降、if文では、
「改行ごとにリストに詰め込んだ文章データを、できるだけまとめて照応解析に投げる」ことをゴリゴリ実装しています。

単純な文章長だけみるのではなく、リストであることにこだわったのは、文章の切れ目で照応解析を実施しないと精度に影響がでるかもしれないと思ったからです。(予想であり未検証)

call_api_count <= max_call_api_count
は無料プランだと各API1000コール/日なので、APIのコール数をある程度制御したいという悪あがきをしました。

レスポンスをjson形式からクラスに割り当てる。

ここは好みの問題だとも思いますが、
APIのレスポンスをクラスに割り当てた方が、辞書型でそのまま使うよりは使いやすくないですかね?って提案部です。

COTOHA APIだとQiita記事上では辞書型の方が多数派みたいなので、照応解析について参考を載せておきます。

まず、照応解析のレスポンスがどういったjson形式でやってくるのか、公式の例を見てましょう
(例によって「太郎は友人です。彼は焼き肉を食べた。」を投げた場合)

coreference.json
{
  "result" : {
    "coreference" : [ {
      "representative_id" : 0,
      "referents" : [ {
        "referent_id" : 0,
        "sentence_id" : 0,
        "token_id_from" : 0,
        "token_id_to" : 0,
        "form" : "太郎"
      }, {
        "referent_id" : 1,
        "sentence_id" : 1,
        "token_id_from" : 0,
        "token_id_to" : 0,
        "form" : "彼"
      } ]
    } ],
    "tokens" : [ [ "太郎", "は", "友人", "です" ], [ "彼", "は", "焼き肉", "を", "食べ", "た" ] ]
  },
  "status" : 0,
  "message" : "OK"
}

これを割り当てることができるclassの定義は以下です。
公式のリファレンスとにらめっこしてたらなんとなく分かるはずです。jsonの階層が深いところから定義しましょう。

resobj/coreference.py
# -*- coding: utf-8; -*-
from dataclasses import dataclass, field
from typing import List

# エンティティオブジェクト
@dataclass
class Referent:
    # エンティティのID
    referent_id: int
    # エンティティが含まれる文の番号
    sentence_id: int
    # エンティティの開始形態素番号
    token_id_from: int
    # エンティティの終了形態素番号
    token_id_to: int
    # 対象の照応詞
    form: str

# 照応解析情報オブジェクト
@dataclass
class Representative:
    # 照応解析情報ID
    representative_id: int
    # エンティティオブジェクトの配列
    referents: List[Referent] = field(default_factory=list)

# 照応解析結果オブジェクト
@dataclass
class Result:
    # 照応解析情報オブジェクトの配列
    coreference: List[Representative] = field(default_factory=list)
    # 解析対象文の各文を形態素解析して得られた表記の配列
    tokens: List[List[str]] = field(default_factory=list)

# レスポンス
@dataclass
class Coreference:
    # 照応解析結果オブジェクト
    result: Result
    # ステータスコード 0:OK, >0:エラー
    status: int
    # エラーメッセージ
    message: str

引っかかった場所
「Referent」クラスのformフィールドがなぜか公式のリファレンスに説明がなかったことと、、
「Result」クラスのtokensList[List[str]]であることに気づくことに時間がかかりました。

jsonをクラスに割り当てるメソッド(main.pyにもjson_to_coreferenceは記載されています)

json_to_obj.py
# -*- coding:utf-8 -*-
import json
import codecs
import marshmallow_dataclass
from respobj.coreference import Coreference

def json_to_coreference(jsonstr):
    json_formated = codecs.decode(json.dumps(jsonstr),'unicode-escape')
    result = marshmallow_dataclass.class_schema( Coreference )().loads(json_formated)
    return result

dataclasses,marshmallow_dataclass様様といった実装になっています。marshmallow_dataclassはインストールされていないことの方が多いかもしれません。(PyPIのページ)

ここが今回Python3.7でなければならない主な理由です。
私がこちらの方式を推すのは、仕様変更があっても対応箇所がわかりやすい・対応が早くすむんじゃないかなーっと思うからです。(単純にPythonの辞書型に慣れてないだけのようにも思うので、参考程度でお願いします)

参考サイト
pythonのクラスをJSON化

一つの照応詞にまるめてからファイルに保存する。

ここで問題になるのが「どの照応詞でまるめるか」です。
今回は、文章上で先に出てきたものが本体だろうという安易な予測の元、先に出てきた照応詞でまとめる実装とします。
※前半部でresultのリストに「Coreference」クラスの各要素(通信回数分)が格納されている状態

main.py
    #後半部分
    for obj in result:
        coreferences = obj.result.coreference
        tokens = obj.result.tokens
        for coreference in coreferences:
            anaphor = []
            # coreference内の最初の照応詞を元とする。
            anaphor.append(coreference.referents[0].form)
            for referent in coreference.referents:
                sentence_id = referent.sentence_id
                token_id_from = referent.token_id_from
                token_id_to = referent.token_id_to
                # 後続の処理のためにlistの要素数を変更しないように書き換える。
                anaphor_and_empty = anaphor + ['']*(token_id_to - token_id_from)
                tokens[sentence_id][token_id_from: (token_id_to + 1)] = anaphor_and_empty
        # 変更後の文章をファイルに保存する
        with open(result_txt_path, mode='a') as f:
            for token in tokens:
                line = ''.join(token)
                f.write(line + '\n')

sentence_idtokensの中の文章の何番目の要素(文章)か
token_id_fromtoken_id_tosentence_id番目の文章中の形素解析した要素のtoken_id_from番目からtoken_id_to番目が照応詞に該当することを意味しています。

書き換える照応詞はcoreference.referents[0].formで求めて、
書き換える時に

                # 後続の処理のためにlistの要素数を変更しないように書き換える。
                anaphor_and_empty = anaphor + ['']*(token_id_to - token_id_from)
                tokens[sentence_id][token_id_from: (token_id_to + 1)] = anaphor_and_empty

といった感じに小細工をします。
(書き換えられる要素数と書き換える要素数を無理矢理合わせている)
小細工しないとtoken_id_fromtoken_id_toの番号が狂います。(もっとうまい方法があったら教えてください)

main.pyの全容
main.py
# -*- coding:utf-8 -*-
import os
import json
import configparser
import datetime
import codecs
import cotoha_function as cotoha
from aozora_scraping import get_aocora_sentence
from respobj.coreference import Coreference
from json_to_obj import json_to_coreference

if __name__ == '__main__':
    # ソースファイルの場所取得
    APP_ROOT = os.path.dirname(os.path.abspath( __file__)) + "/"

    # 設定値取得
    config = configparser.ConfigParser()
    config.read(APP_ROOT + "config.ini")
    CLIENT_ID = config.get("COTOHA API", "Developer Client id")
    CLIENT_SECRET = config.get("COTOHA API", "Developer Client secret")
    DEVELOPER_API_BASE_URL = config.get("COTOHA API", "Developer API Base URL")
    ACCESS_TOKEN_PUBLISH_URL = config.get("COTOHA API", "Access Token Publish URL")

    # 定数
    max_word = 1800
    max_call_api_count = 150
    max_elements_count = 20
    # 青空文庫のURL
    aozora_html = 'https://www.aozora.gr.jp/cards/000155/files/832_16016.html'
    # 現在時刻
    now_date = datetime.datetime.today().strftime("%Y%m%d%H%M%S")
    # 元のテキストを保存するファイルのパス
    origin_txt_path = './result/origin_' + now_date + '.txt'
    # 結果を保存するファイルのパス
    result_txt_path = './result/converted_' + now_date + '.txt'

    # COTOHA APIインスタンス生成
    cotoha_api = cotoha.CotohaApi(CLIENT_ID, CLIENT_SECRET, DEVELOPER_API_BASE_URL, ACCESS_TOKEN_PUBLISH_URL)

    # 青空文庫のテキストの取得
    sentences = get_aocora_sentence(aozora_html)
    # 比較用に元のテキストを保存
    with open(origin_txt_path, mode='a') as f:
        for sentence in sentences:
            f.write(sentence + '\n')

    # 初期値
    start_index = 0
    end_index = 0
    call_api_count = 1
    temp_sentences = sentences[start_index:end_index]
    elements_count = end_index - start_index
    limit_index = len(sentences)
    result = []
    print("リストの総数" + str(limit_index))
    while(end_index <= limit_index and call_api_count <= max_call_api_count):
        length_sentences = len(''.join(temp_sentences))
        if(length_sentences < max_word and elements_count < max_elements_count and end_index < limit_index):
            end_index += 1
        else:
            if end_index == limit_index:
                input_sentences = sentences[start_index:end_index]
                print('インデックス : ' + str(start_index) + 'から' + str(end_index) + 'まで')
                # 終了条件
                end_index += 1
            else:
                input_sentences = sentences[start_index:end_index - 1]
                print('インデックス : ' + str(start_index) + 'から' + str(end_index-1) + 'まで')
            print(str(call_api_count) + '回目の通信')
            response = cotoha_api.coreference(input_sentences)
            result.append(json_to_coreference(response))
            call_api_count += 1
            start_index = end_index - 1
        temp_sentences = sentences[start_index:end_index]
        elements_count = end_index - start_index

    for obj in result:
        coreferences = obj.result.coreference
        tokens = obj.result.tokens
        for coreference in coreferences:
            anaphor = []
            # coreference内の最初の照応詞を元とする。
            anaphor.append(coreference.referents[0].form)
            for referent in coreference.referents:
                sentence_id = referent.sentence_id
                token_id_from = referent.token_id_from
                token_id_to = referent.token_id_to
                # 後続の処理のためにlistの要素数を変更しないように書き換える。
                anaphor_and_empty = anaphor + ['']*(token_id_to - token_id_from)
                tokens[sentence_id][token_id_from: (token_id_to + 1)] = anaphor_and_empty
        # 変更後の文章をファイルに保存する
        with open(result_txt_path, mode='a') as f:
            for token in tokens:
                line = ''.join(token)
                f.write(line + '\n')

処理結果

夏目漱石「心」

FileMergeで元の文章と変換後の文章の一部を比べた画像(左がオリジナル、右が変換後)
スクリーンショット 2020-03-04 1.53.24.png

すごいぞCOTOHA API

before:

ある鹿児島人を友達にもって、その人の真似をしつつ自然に習い覚えた私は、この芝笛というものを鳴らす事が上手であった。
私が得意にそれを吹きつづけると、先生は知らん顔をしてよそを向いて歩いた。


after:

ある鹿児島人を友達にもって、ある鹿児島人の真似をしつつ自然に習い覚えた私は、この芝笛というものを鳴らす事が上手であった。
私が得意にこの芝笛というものを吹きつづけると、先生は知らん顔をしてよそを向いて歩いた。

「その人」が「ある鹿児島人」に「それを吹き続けると」が「この芝笛というものを吹きつづけると」になっています。

どうかな? COTOHA API

before:

私は早速先生のうちへ金を返しに行った。例の椎茸もついでに持って行った。
~6文くらい略~
先生は腎臓の病について私の知らない事を多く知っていた。
「自分で病気に罹っていながら、気が付かないで平気でいるのがあの病の特色です。
私の知ったある士官は、とうとうそれでやられたが、全く嘘のような死に方をしたんですよ。
~

after:

私は早速先生のうちへ金を返しに行った。例の椎茸もついでに持って行った。
~6文くらい略~
先生は腎臓の病について私の知らない事を多く知っていた。
「自分で病気に罹っていながら、気が付かないで平気でいるのが病気の特色です。
私の知ったある士官は、とうとう例の椎茸でやられたが、全く嘘のような死に方をしたんですよ。
~

例の椎茸…
その他にも苦しいところは多々あります。記号もうまく処理できていない状態です。

おまけ

江戸川乱歩「おれは二十面相だ」

スクリーンショット 2020-03-04 2.00.20.png

紀貫之「土佐日記」

古文はそこまで対応してないと思います。半分ネタ(ただそれっぽいところもあって面白いです)

スクリーンショット 2020-03-04 2.03.34.png

共に左がオリジナル、右が変換後です。

最後に

最終的に「派手なことをやる前に照応解析で前処理をやったら、他の自然言語処理の結果も変わってくる(より精度が上がる)のでは?」の仮定が本当か否かは、なんとも言えない結果となりました。より検証が必要であると思います。

変換精度をよくするには「どの照応詞でまるめるか」問題はもっとよく考えた方が良さそうですし、文章をどういう枠組みで捉えるかも重要かもしれません。

日本語って指示詞・代名詞のオンパレードなので、完璧は難しそうですが、気持ちよく変換できているところもあるので、COTOHA APIはかなりの可能性を感じます。
以上です!

kronos-jp
AI開発・WEB開発・システム開発・Android開発・iOS開発・IT研修・トレーニング・新入社員研修などを行う企業です。
https://www.kronos.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした