こんにちは!SAS Institute Japanの堀内です。今回も自然言語処理について紹介いたします。
第1回目の投稿では、最近の自然言語処理の応用例とSAS社が携わった自然言語処理関連の実案件の概要を紹介しました。
第2回目の本投稿では実際にSASを使って日本語の文章を扱う自然言語処理の例を解説していきます。
テキストデータって何?
自然言語処理を語る前に、自然言語処理が処理対象とするデータのことを知る必要があります。自然言語処理で扱われるデータはテキストデータと呼ばれています。ここからはテキストデータがどういうものか探っていきます。
テキストとは以下のようなものです。
「自然言語処理で扱われるデータはテキストデータと呼ばれています。本投稿ではテキストデータがどういうものか探っていきます。」
何の変哲もない日本語の文章です。日本語以外の言語で書かれた文章ももちろんテキストと呼ばれます。
ではテキストデータとは何でしょう?データと言うからには何らかの構造を持っていると考えます。例えば行と列が与えられたテーブルデータがわかりやすい例です。
テキストデータと呼ぶとき、テキストに何らかの構造を与えられたものを想起すると良いかと思います。上で挙げたサンプルのテキストをテキストデータに変換してみましょう。
["自然言語処理で扱われるデータはテキストデータと呼ばれています。", "本投稿ではテキストデータがどういうものか探っていきます。"]
これは句読点でテキストを区切り、リストに格納した例です。やりかたは他にもあります、
[["自然言語処理", "で", "扱われる", "データ", "は", "テキストデータ", "と", "呼ばれて", "います", "。"], ["本投稿", "では", "テキストデータ", "が", "どういうもの", "か", "探って", "いきます", "。"]]
これは先ほどの例で2つのテキストに区切ったうえで、それぞれのテキストを更に単語ごとに区切って別々のリストに格納した例になります。これをテーブルデータのように整えると、
ID | COL1 | COL2 | COL3 | COL4 | COL5 | COL6 | COL7 | COL8 | COL9 | COL10 |
1 | 自然言語処理 | で | 扱われる | データ | は | テキストデータ | と | 呼ばれて | います | 。 |
2 | 本投稿 | では | テキストデータ | が | どういうもの | か | 探って | いきます | 。 | [PAD] |
となります。行方向にIDを、列方向に単語を保持したテーブルデータができあがりました。2行目10列目に[PAD]という見慣れない記号が入っていますが、これは全行にわたり列数を同一にするための埋め合わせのための特殊な単語です。
このように、テキストに構造を与えることで、機械が理解しやすいテキストデータを得ることができます。
なお、ここに挙げた方法はあくまでも一例です。構造化の手法は様々な方法があり、対象とする言語、目的とするタスク、利用するモデルに応じて最適な方法を選ぶことが求められます。
次に、テキストの構造化にあたり、言語ごとの違いと共通点について詳しく見ていきます。
構造化の手法は対象とする言語によって異なります。ここでは英語と日本語でどのように構造化の手法が違うのか見ていきましょう。
分かち書き
テキストを何らかのルールに基づいて分割して表記することを分かち書きといいます。英語の場合は初めから単語と単語の間に空白(スペース)が挿入されているので、一応既に分かち書きが完了している状態です。日本語の場合はそのような空白を挿入することはしないため、分かち書きの形式に書き直す作業が別途発生します。こう書いてしまうと日本語はひと手間余計にかかるから面倒だと思われるかもしれませんが、実は英語も分かち書きの見直しが必要になるため、英語と日本語で必要になる労力にはそれほど違いは無い、というのが実際のところです。例を見てましょう。
英語例: "I thought what I'd do was, I'd pretend I was one of those deaf-mutes."
日本語例: "僕は耳と目を閉じ、口を噤んだ人間になろうと考えたんだ。"
(村上春樹訳『キャッチャー・イン・ザ・ライ』より)
これらを分かち書きにすると、
英語例: ["I", "thought", "what", "I", "'d", "do", "was", ",", "I", "'d", "pretend", "I", "was", "one", "of", "those", "deaf", "-", "mutes", "."]
日本語例: ["僕", "は", "耳", "と", "目", "を", "閉じ", "、", "口", "を", "噤ん", "だ", "人間", "に", "なろう", "と", "考え", "た", "ん", "だ", "。"]
ということになります。こちらは先ほどの例とは違い、プログラムを使って行いました。なお、プログラミングコードの行数は英語も日本語もほぼ同じです。そういう意味では手間暇の違いは両者ほぼありません。(ひと昔前までは、言語ごとに専用の分かち書き器をインストールするなど、言語ごとに手間の違いが際立っていましたが、現在はそのあたりはあまり意識する必要がなくなりつつあります。)
なお、SASではtpParseアクションセット1で分かち書きを行います。言語を指定してやるだけでその言語に対応した分かち書きを行ってくれます。対応している言語は本稿記載時点(2022年7月)で以下の通りです:
"ARABIC" | "CHINESE" | "CROATIAN" | "CZECH" | "DANISH" | "DUTCH" | "ENGLISH" | "FINNISH" | "FRENCH" | "GERMAN" | "GREEK" | "HEBREW" | "INDONESIAN" | "ITALIAN" | "JAPANESE" | "KOREAN" | "NORWEGIAN" | "POLISH" | "PORTUGUESE" | "RUSSIAN" | "SLOVAK" | "SLOVENE" | "SPANISH" | "SWEDISH" | "THAI" | "TURKISH" | "VIETNAMESE"
日本語にもしっかり対応しているので安心ですね。実際にコードと処理結果を見てみましょう。今回はPythonをインターフェースとして利用します。
まずSWAT2とpandasをインポートし、SAS ViyaのCAS3に接続したのち、データとtextParseアクションセットをロードします。
import swat import pandas as pd conn = swat.CAS('mycashost.com', 5570, 'username', 'password') df = pd.DataFrame({'Id':[0,1], 'text': ["I thought what I'd do was, I'd pretend I was one of those deaf-mutes.", "僕は耳と目を閉じ、口を噤んだ人間になろうと考えたんだ。"]}) conn.upload(df, casout=dict(name="BLOG_NLP_SAS_RYE", promote = False)) conn.loadactionset('textParse') |
後はtextParseアクションセットをデータに対して実行するだけです。英語の場合と日本語の場合、両方やってみます。
英語の場合:
conn.textparse.tpparse(docid='Id', table='BLOG_NLP_SAS_RYE', text='text', language='ENGLISH', offset='RYE_PARSED_EN') output = conn.CASTable(name='RYE_PARSED_EN') df_output = output.to_frame() display(df_output) |
日本語の場合:
conn.textparse.tpparse(docid='Id', table='BLOG_NLP_SAS_RYE', text='text', language='JAPANESE', offset='RYE_PARSED_JP') output = conn.CASTable(name='RYE_PARSED_JP') df_output = output.to_frame() display(df_output) |
さて、英語の分かち書きに着目すると、もとの文では"I'd"はひとまとまりでしたが、改めてプログラムによる分かち書きの見直しにより["I", "'d"]に分かれたことが見て取れます。"'d"は"would"の省略形で、別個に扱ったほうが良いという判断からです。またコンマ","やピリオド".", ハイフン"-"なども独立して扱われるようになりました。このように英語の場合でも結局は人間が行う分かち書きとは異なる方法での分かち書きが必要になります。
形態素 (morpheme) とトークン (token)
ところで分かち書きをする際、どこでテキストを分かつのか、という問題が常にあります。意味を持つ最小の単位でテキストを区切っていくのが一般的な方法です。この意味を持つ最小の単位とは何でしょう?単語と言ってしまっても良いのですが、2単語以上で1つの最小単位を構成する場合 (地名や社名など) があるため別の呼び方をするほうが適当です。この最小単位のことを、言語学では形態素 (morpheme) 、計算機科学ではトークン (token) と言ったりします。自然言語処理は言語学と計算機科学のどちらからも影響を受ける分野なので形態素と言ったりトークンと言ったりしますが、どちらも「意味を持つ最小の単位」を指していると理解しておけばまず問題ないでしょう。特に最近は計算機科学の影響が強くなってきているのかトークンと呼ぶことが多くなっているようですので本稿でも以後はトークンと呼ぶことにします。なおトークンは見出し語の形に直しておくことで記憶すべきボキャブラリーの数を減らすことができます。ちなみにボキャブラリーとは語彙のことで、機械が把握すべきトークンの種類のことを指します。
英語例: ["I", "thought", "what", "I", "'d", "do", "was", ",", "I", "'d", "pretend", "I", "was", "one", "of", "those", "deaf", "-", "mutes", "."]
→ ["I", "think", "what", "I", "would", "do", "be", ",", "I", "would", "pretend", "I", "be", "one", "of", "those", "deaf", "-", "mute", "."]
日本語例: ["僕", "は", "耳", "と", "目", "を", "閉じ", "、", "口", "を", "噤ん", "だ", "人間", "に", "なろう", "と", "考え", "た", "ん", "だ", "。"]
→ ["僕", "は", "耳", "と", "目", "を", "閉じる", "、", "口", "を", "噤む", "だ", "人間", "に", "なる", "と", "考える", "た", "ん", "だ", "。"]
もし見出し語の形に直さない場合, "would"と"'d"、"閉じ"と"閉じる"、"考え"と"考える"なども別個のトークンとしてボキャブラリーに保持する必要が出てきますので自ずとボキャブラリーの数が増えてしまいます。ボキャブラリーの数が増えるとそれだけ計算コストが高くなります。見出し語化することでそういったことを避けることができます。
テキストデータをベクトルに変換
分かち書きが完了したテキストデータはベクトルに変換することができます。コーパスに先ほどの例の2文しか存在しない場合を考えてみましょう。
なお、コーパスとは文の集合を指します。ここでは簡単のため"I thought what I'd do was, I'd pretend I was one of those deaf-mutes."と"僕は耳と目を閉じ、口を噤んだ人間になろうと考えたんだ。"の2文のみを含んだコーパスを想定します。
このコーパスから作成される構造化テーブルは以下の通りです:
左端列tokenにトークンが並んでいるのがわかるかと思います。2列目以降がボキャブラリーです。ボキャブラリーの数は33個です。"I"のベクトルが[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0]、"think"のベクトルが[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1]ということになります。これは該当するボキャブラリーの位置に1が立ち、それ以外は0になるone-hotベクトルになります。ベクトルの次元数は33ですが、これはボキャブラリーの個数と一致しています。ボキャブラリーの個数が増えるほどベクトルの次元数は増えます。
今回のコーパスには短文が2つしか含まれていないのでボキャブラリーの個数は少なく抑えられていますが、実際にはコーパスは膨大な文章の集合であることが多いのでボキャブラリーの個数もそれに応じて相当な数になります。こうなると必要なのはベクトルの次元削減になります。
SVDによるベクトルの次元削減
テーブルデータの次元削減でよく用いられるPCA (主成分分析) と似たような働きをするのがSVD (特異値分解) です。中でもSVDの亜種であるtruncated SVDと呼ばれる手法はテキスト解析の分野ではLSA (潜在意味解析) と呼ばれ昔から使われてきましたので、ここでもtrunceted SVDを使って先ほどの33次元のone-hotベクトルを3次元にまで次元削減してみましょう。
truncated SVDを適用した後の構造化テーブルは以下の通りです:
今回も1列目にトークンが、2列目以降にベクトルの要素が並んでいます。行数は先ほどと変わりませんが、列数は一挙に4にまで減りました。"I"のベクトルは[0.9999998810521791,2.1154252111029098e-08,-3.032275682307832e-06]、"think"のベクトルは[1.802928801428313e-05,0.0012932634768232278,-0.0025172057697884116]になります。ベクトルの次元数が3であることが分かります。なお、次元数が3になることで各要素をx,y,z軸に対応させた3次元プロットによる可視化が可能になります。ちょっとやってみましょう。
ちょっと見えにくいですが、各トークンが3次元空間上の点としてプロットされています。これがいわゆるベクトル空間と呼ばれるものになります。
各トークンのベクトルを使って文章自体をベクトル空間上にプロットすることも可能です。
doc,feature0,feature1,feature2 "I thought what I'd do was, I'd pretend I was one of those deaf-mutes.",0.20000462041364359,0.016675816872855288,0.01004606724407839 僕は耳と目を閉じ、口を噤んだ人間になろうと考えたんだ。,1.35094819219522e-05,-0.01715732209553769,0.10422534377452466
このように単なるテキストを分かち書きし、構造を与え、ベクトルに変換することでディープラーニングのモデルで扱えるようになります。
なおtruncated SVDなどの手法を使って次元削減をしておけば、ボキャブラリーの数が増えても次元数はtruncated SVDで指定する次元数(上の例では3)のまま変化しません。ディープラーニングのモデルは、この次元削減後のベクトルを使ってパラメータの推定をしますので、ボキャブラリーの数の増減の影響を直接受けずに済むというメリットがあり、大規模コーパスの取り扱いが前提となる近年の自然言語処理のデファクトスタンダードになっています。
共起行列によるベクトル表現
最後に共起行列によるベクトル化をSASのtmCooccurアクションセットでやってみましょう。
まずは必要なアクションセットとデータをロードします。
conn.loadactionset('textMining') conn.loadactionset('textUtil') conn.loadactionset('fedsql') with open(CFG.local_input_file_path, encoding='cp932') as f: lines = f.readlines() df = pd.DataFrame({'Id':[i for i in range(len(lines))], 'text': lines}) conn.upload(df, casout=dict(name=CFG.in_cas_table_name, promote = False)) |
データフレームにはディケンズ『二都物語』のテキストが格納されています。
次にトークン化とトークン対トークンの共起行列の作成、truncated SVDによる次元削減をステップ・バイ・ステップに行います。
conn.tmMine(docId="Id", documents=CFG.in_cas_table_name, entities="NONE", nounGroups=False, offset=dict(name='pos', replace=True), reduce=1, stemming=True, tagging=False, terms=dict(name='terms', replace=True), text="text", language='JAPANESE') conn.tmCooccur(cooccurrence=dict(name='cooc', replace=True), maxDist=5, minCount=0, offset={"name":"pos"}, ordered=False, terms={"name":"terms"}, useParentId=True) conn.tmSvd(count="_association_", docId="_termid2_", maxK=5, parent={"name":"cooc"}, termId="_termid1_", wordPro=dict(name='wordPro', replace=True)) |
結果テーブルを確認してみましょう。
conn.execDirect(casout=dict(name='wordEmbedding', replace=True), query='select t._term_, d.* from wordPro d, terms t where d._termnum_=t._termnum_') conn.fetch('wordEmbedding', to=50) |
トークンごとに5次元のベクトルが獲得されていることが分かります。tmSvdアクションセットのmaxKでベクトルの次元数を設定できます。300次元のベクトルを取得してみましょう。
conn.tmSvd(count="_association_", docId="_termid2_", maxK=300, parent={"name":"cooc"}, termId="_termid1_", wordPro=dict(name='wordPro', replace=True)) conn.execDirect(casout=dict(name='wordEmbedding', replace=True), query='select t._term_, d.* from wordPro d, terms t where d._termnum_=t._termnum_') conn.fetch('wordEmbedding', to=50) |
以上、言語ごとの違いと共通点について詳しく見てきました。日本語の処理も比較的容易に行えることが分かっていただけたら幸いです。
次回はSASでできる自然言語処理の代表的なタスクを紹介していきます。
1. アクションセット: SAS Viyaが提供する各種メソッドを盛り込んだパッケージのことで、SASプログラミング言語の他、Python、R、Luaから呼び出して利用することができる。
2. SWAT: SAS Scripting Wrapper for Analytics Transferの略で、Python用のCASインターフェース。PythonからCASを利用する際に使用する。
3. CAS: SAS Cloud Analytic Servicesの略で、SAS Viyaのインメモリ分散並列処理機構のこと。