自然言語のパーサーとシンセサイザーを構築するための厳密に型指定されたコンビネーター

有名なParserCombinatorParboiledは、形式言語の解析専用に設計されています。 自然言語の解析の問題を解決すると同時に、同じ文法を使用して、必要なセマンティクスを反映する自然言語のフレーズを合成したいと考えています。 抽象化/具体化の規則とともに言語構成を記述することができると便利です。



例えば



  1. 数字を数字に変換する( "ten"-> 10:Int)
  2. その逆(10:Int-> "ten"( "tenth"、 "ten" ...))
  3. 数量単位を使用した数値の変換(「10ルーブル」<-> NumberWithMeasurement(10、RUB))
  4. 不完全な住所( "Yablochnaya St." <->住所(street = "Yablochnaya"))
  5. 市内の住所(「ストリートアップルハウス123アパートメント45」<->住所(ストリート=「アップル」、建物= 123、フラット= 45))
  6. 電話(256-00-21(「256ゼロゼロ21」))<-> NumericalSequence(256,0,0,21))


また、次のシステムプロパティが必要です。





猫の下-synapse-typed-expressionsライブラリに実装されたアプローチの説明。 数字のみが考慮されますが、アプローチは自然に上記の他の正式な言語構成に拡張されます。





抽象化のレベル



同じセマンティクス(意味のある情報)が、さまざまな抽象化レベルでさまざまな形式で提示されます。 たとえば、1234という数字がさまざまな抽象化レベルでどのように見えるかを考えてみましょう。



  1. 1234:Int-アプリケーションのビジネスロジックレベル
  2. 番号(1234L)
  3. TwoClassNumber(数値(1L)、順序(1000)、数値(234L))-数値を2つのクラス(千単位と単位)に分割します
  4. Seq(番号(1L)、注文(1000)、番号(234L))-コンポーネントチェーン
  5. Seq(数値(1L)、順序(1000)、TwoRangesNumber(数値(200L)、100、数値(34L)))
  6. Seq(数値(1L)、順序(1000)、TwoRangesNumber(数値(200L)、100、TwoRangesNumber(数値(30L)、10、数値(4L))))-数値の構造に縮小
  7. Seq(ワード(1L)、ワード(1000L)、ワード(200L)、ワード(30L)、ワード(4L))-ワードIDのチェーン
  8. Seq(1、1000、200、30、4)-単語形式のマッチングのルールを適用した後の単語のチェーン
  9. 「千二百三十四」-テキスト。


この例では、抽象的に強く型付けされた値1234からロシア語のテキストへの具体化の下降プロセスを観察できます。 具象化の各ステップは、置換ルールを使用して説明できます。



パーサーを実装するには、同じ置換ルールを使用して、たとえばバックトラッキングメカニズムやCKYパーサーを使用して比較を行う必要があります。



コンテンツ自体またはすべてのレベルのセマンティクスは変更されないことに注意してください。 セマンティクスの表現形式のみが変更されます。 この場合、抽象化/具体化の軸上の方向を明確に定義します。



次に行うことができるのは、上方向への移動(抽象化レベルの増加)により、一部の詳細(たとえば、数字をその構成要素に分割する単語の形式)を順次破棄することです。 そして、下方への動き(具体化、統合)では、既存のセマンティクスにいくつかの詳細(数字のタイプは序数または量、単語の形式など)を追加する必要があります。 欠落情報の仕様が提供されます。







数字の文法規則



さまざまな数値範囲の具体化規則について説明します。



 「番号1〜9」:= ID1またはID2または... ID9
 「番号10から19」:= ID10またはID11または... ID19
 「番号1〜19」:= ID1またはID2または... ID19
 「20から90の範囲」:= ID20またはID30または... ID90
 「100から900までの数百」:= ID100またはID200または... ID900
 「1つの単語で表される数」:=「100〜900の数百」または「20〜90の数十」または「1〜19の数」


上記のルールを使用すると、ロシア語の単一の識別子の形式で、セットの番号(1、2、... 20、30、... 100、200、... 900)を指定できます。 識別子も数字で示され、単語の形式はまだ指定されていません。 私たちのルールはまだすべての数字で機能するとは限りません。 間違った番号を使用しようとすると、失敗します。 また、私たちの規則では、複数の単語を認識できません。



ロシア語の数字の規則に従って編成された2つまたは3つの単語を認識する規則を追加します。



 「2つの単語で表される数」:=(「100から900の数百」、「20から90の数十」) 
                                      または(「100〜900の数百」、「1〜19の数」) 
                                      または(「20から90までの10」、次に「1から9までの数」)
 「3つの単語で表される数」:=「100〜900の数百」、「20〜90の数」、「1〜9の数」


さらなる考慮事項を簡素化するために、数値の範囲と比較されるルールの略記法を導入します。



  1. 「[100..900 / 100]」は、100から900の範囲の数値を100単位で示します。
  2. 「または」の代わりに|アイコンを使用し、「その後」の代わりに〜アイコンを使用します。
  3. ルールの名前の前に、キーワードvalを記述し、ルールの定義そのものを等号の後に記述します。
  4. ID20の代わりに、id関数id(20)を使用します


この記述方法により、ルールをよりコンパクトに記述できます。



	 val `[0]` = id(0L)
	 val `[1..9]` =(1L to 9).map(id)
	 val `[0..9]` = `[1..9]` |  `[0]`
	 val `[1..19]` =(1L to 19).map(id)
	 val `[10..19]` =(10L to 19).map(id)
	 val `[20..90 / 10]` =(20L to 90 by 10).map(id)
	 val `[100..900 / 100]` =(100L to 900 by 100).map(id)
	 val singleWordNumber = `[100..900 / 100]` |  `[20..90 / 10]` |  `[1..19]`


2ワードの数字を想像する方法は? 構文の観点から、このような数字は、シーケンス演算子〜によって関連付けられた2つの式で表されます。 抽象化のレベルを上げるという観点から、2つのコミュニケーション方法があります。加算(「20」と「1」= 20 + 1 = 21)および乗算(「5」「千」= 5 * 1000 = 5000)です。 そして、抽象化のレベルが下がると、残りの除算(21%10 = 1、21-1 = 20)とちょうど除算(5000/1000 = 5)になります。 追加の演算子^^を使用して、抽象化レベル間の変換方法を示します。この演算子は、指定した変換がレベル間に存在することを報告します。 たとえば、20〜99の範囲の2つの単語で表される数字は次のように想像できます。



	 val `[20..99] without 20..90 / 10` =` [20..90 / 10] `〜` [1..9] `^^ ModSplit(10L)


単一単語の数字はこの表現に該当しません。 ペアの2番目のコンポーネント(ユニット)が省略され、数学的な観点からはゼロで表されるという点で異なります。 文法的に、この状況は、空のストリームにマップされるイプシロンシンボルを使用して表すことができ、より高い抽象レベルでは定数によって表されます。 特別なアイコンを使用してこのような現象を指定します|?







	 val `[20..99]` = `[20..90 / 10]`〜( `[1..9]` |?0L)^^ ModSplit(10L)


この式を使用すると、解析を20〜99の範囲の数値と比較できます。また、合成中に、10と1、または10の数値のペアを取得できます。



1〜99の範囲を表すために、 |



演算子を使用して2つの式を組み合わせることができます。 。 具象化中に明確な選択を提供するために、 LessThanSelector(20L)



セレクターを追加します。これは、数値がしきい値より小さい場合に正しい式を選択します。



	 val `[1..99]` = `[20..99]` |  `[1..19]` selectBy LessThanSelector(20L)


同様に、1〜999の範囲の数値が表されます。



	 val `[100..999]` = `[100..900 / 100]`〜( `[1..99]` |?0L)^^ ModSplit(100L)
	 val `[1..999]` = `[100..999]` |  「[1..99]」selectBy LessThanSelector(100L)「1..999」というラベル


1000を超える数を表すには、 OrderSplit(1000)



変換を使用する必要があります。これは、上方向の移動中に1000の数をOrderSplit(1000)



、下方向の移動中に除算を実行します。



	 val `[1 000..999 000/1000]` = `[1..999]`〜 `[1 000]` ^^ OrderSplit(1000L)
	 val `[1 000..999 999]` = `[1 000..999 000/1000]`〜( `[1..999]` |?0L)^^ ModSplit(1000L)
	 val `[1..999 999]` = `[1 000..999 999]` |  `[1..999]` selectBy LessThanSelector(1000L)


任意の範囲の数値の式は、再帰関数を使用して表すことができます



   def range1To999Order(order:Long):NE = order match {
    ケース1L => `[1..999]`
    ケースo if o> = 1000 =>
       val lower = range1To999Order(order / 1000)
       val ordNE =順序:NE
       val upper = `[1..999]`〜* ordNE
       ((上〜+下)|上selectBy OrderSplit(注文))| より低いselectBy RangeSelector(順序)
   }


ここでのorder



は、100万、10億などを示しています。



抽象化レベル間のコミュニケーションルールの説明



プログラミング言語では、さまざまな形式のセマンティクスをさまざまなタイプのデータで表すことができます。 したがって、抽象化レベル間の遷移には、より具体的な形式のタイプ(L-下部)とより抽象的な形式のタイプ(U-上部)の2つのタイプを装備する必要があります。



	封印された形質の表現[L、U]


Expression



タイプは、入力ストリームL



一部とオブジェクトU



に対応する文法を記述します。 このオブジェクトは、ルールの構文コンポーネントと見なすことができます。 オブジェクトをより高いレベルの抽象化にさらに変換するには、 Transformer



の子孫のいずれかで表すことができる関数を使用します。



	シールド特性トランス[M、U]


しかし、一般的に、機能的な変換と一緒に文法表現も式です:



	ケースクラスTransformed [L、M、U](e:式[L、M]、t:Transformer [M、U])は、Expression [L、U]を拡張します


変換関数はすでに、一致する式に何らかの意味解釈をもたらします。 したがって、 Transformer



はルールのセマンティックコンポーネントです。



いずれかのルールを使用してフォームを変換すると失敗する場合があります。 したがって、変換関数は、ルールが適合しなかったことを報告できる必要があります。 これらの目的には、結果/オプション/試行/いずれかのパターンのオプションのいずれかが適しています。



	封印された特性ParseResult [T]
	ケースクラスSuccess [T](値:T、テール:LemmaStream)はParseResult [T]を拡張します
	ケースクラスFailure [T](理由:String)はParseResult [T]を拡張します


ルール自体はPair, BooleanAlternative, MapAlternative, ConstExpr



上記のDSLで構築されたケースクラスのセットを使用して表されます- Pair, BooleanAlternative, MapAlternative, ConstExpr







辞書ビュー



辞書に関しては、解析と合成の問題の要件はわずかに異なります。 構文解析(抽象化の増加)の目的のために、より高い抽象化レベルで必要な情報のみを各単語に関連付ける必要があります。 特に、ほとんどの場合、上位レベルで最終的な数値を形成するには、単語に関連付けられた数値を知るだけで十分です。

ただし、テキストを合成するためには、適切な単語を選択できるように、単語の形態学的形態を知る必要があります。 形態学的形式は、性別、数、ケースなどの文法カテゴリの一連の意味として表すことができます。



一般的に、数字を照合するタスクでは、標準の文法カテゴリを使用して単語の形態学的形式を記述する必要はありません。 文法カテゴリの組み合わせの限られたセットのみが実際に使用されるため、代理の文法カテゴリである「調整グループ」を完全に省くことが可能です。 そのようなグループは3つだけです。ユニット、2〜4、および4以上です。「一致するグループ」内のすべての数字は、単語形式の選択に関して同じように動作します。

ただし、この問題は数字の変換よりもある程度広く考えており、住所や電話番号などの正式な言語構成を含んでいるので、それでも標準の文法分類に従う方が便利です。



文法的なカテゴリとその意味(「文法」)の両方をオブジェクトとして表しているため、それらをキー値として使用すると便利です。



	ケースオブジェクトGenderはGrammarCategoryを拡張します{
	   val default = Masculine
	  ケースオブジェクトMasculineはGrammemを拡張します
	  ケースオブジェクトfeminiはgrammemを拡張します
	  ケースオブジェクトNeuterはGrammemを拡張します
	 }


Grammem



タイプGrammem



Grammem



宣言されGrammarCategory



。これにより、コンパイル時に文法タイプが文法カテゴリタイプと互換性があることが保証されます。 特に、文法的な種類の文法(たとえば、 Genger.Grammem



)が必要な場合は、型をGenger.Grammem



として宣言できます。 また、同時に、 Gender



オブジェクトを文法カテゴリの値のリストのキーとして使用できます。



辞書自体はペアのコレクションです-(単語の文字列表現、文法的特徴)。 辞書は小さなDSLを使用して形成されるため、すべての詳細をコンパクトに記録できます。



   lazy val allWordForms = {
    アソシエイト(「3 4 5 6 7 8 9」、
       3Lから9、新しいWordFormDescription(男性、枢機,、主格、1)):::
      準(「ゼロ」、
        リスト(0L)、新しいWordFormDescription(Cardinal、Nominative、Ones)):::
      準(「ワンツー」、
        リスト(1、2)、新しいWordFormDescription(Femini、Cardinal、Nominative、Ones)):::
      準(「ワンツー」、
        リスト(1、2)、新しいWordFormDescription(男性、枢機,、主格、1)):::
      準(「1」、
        リスト(1)、simpleNumericalWordForm(Ones、Neuter)):::
      アソシエイト(「10 11 12 13 14」+
         「15、16、17、18、19」
         10Lから19、simpleNumericalWordForm(Teens)):::
    ...
      アソシエイト(「ゼロ、1、2、3、4、5、6、7、8、8、9」、
         0Lから9、新しいWordFormDescription(男性、序数、生成、1)):::
    ...
       associationOrder( "million million million"、1,000,000L):::
    ...
      準(「マイナス」、リスト(-1L)、WordFormDescription.empty)




宣言ルールを使用した解析



説明した文法に基づいて、何らかのインタープリターを使用して直接解析するか、元の文法を変換してパーサーを構築できます。 エラーが許可されている自然なテキストを解析するには、CKYなどの確率的パーサーが必要です。



  1. バックトラッキング

    解析プロセスのテキストのセクションは、上記のルールで指定されたテンプレートと比較されます。 ルールが成功を返した場合、比較が成功したと考えられ、さらに解析を続けることができます。 ルールが適合しない場合、代替のマッチング方法を見つけるためにバックトラッキングを実行します。 不変のデータ構造を使用する場合、失敗したオプションを切断して代替パスを選択するポイントに戻るステップは非常に簡単です。結果を無視して解析を続行します。 データを元の状態に調整する必要はありません。







  2. CKYパーサー

    確率的CKYパーサーを使用するには、最初に規則を通常のチョムスキー形式の文法に変換する必要があります。 この場合の文法はバイナリツリーになるため、この文法を元の単語チェーンと比較するすべての可能な方法を体系的に並べ替えることができます。 同時に、異なる解析オプションに異なる確率が割り当てられます(特に、置換エラーのあるテキストを即座に分析できます)。 そのような変換の過程で、解析アルゴリズムの観点から同等の端末を一種の非端末-事前端末に結合することが有用であることが判明しました。











バックトラッキングパーサーの設計



この記事の目的には、バックトラッキングアルゴリズム(「クリッピングとリターンアルゴリズム」)で十分です。 パーサーは、補題ストリーム( LemmaStream



)を受け入れ、成功した場合は値とストリームの末尾を返す関数です。 尾はさらに分析するために使用できます。 失敗した場合、「クリッピングとリターン」アルゴリズムはこのパーサーを破棄し、代替オプションを試行します。 このように、「戻り」は、不変構造の使用と各レベルでの補題フローのテールの存在によって実現されます。

パーサーの結果は、 Success



Failure



2つの子孫を持つParseResult[T]



で表すことができます。



式は、 backTrackingParser



関数を使用してパーサーにbackTrackingParser



れます。



   def backTrackingParser [U](e:式[LemmaStream、U]):パーサー[U] = {
    暗黙的なdef uncheckedGenerics [T [_]、O](t:T [_]):T [O] = t.asInstanceOf [T [O]]
    暗黙的なdef uncheckedGenerics2 [T [_、_]、O、P](t:T [_、_]):T [O、P] = t.asInstanceOf [T [O、P]]

     def backTrackingParser0(e:式[_、_]):パーサー[_] = e match {
       case Epsilon(u)=> s => Success(u、s)
       case ConstExpression(l:LemmaStream、u)=>(s:LemmaStream)=> startsWithAndTail(l、s).map(t => u)
      ラベル付きケース(_、expr)=> backTrackingParser0(expr)
      ケースの選択肢(式)=>
         val parsers = expression.map(backTrackingParser0)
         (s:LemmaStream)=>
          パーサー。
            マップ(パーサー=>パーサー(s))。
             dropWhile(_。isFailure)。
             headOption.getOrElse(失敗())
      ケースペア(e1、e2)=>
         val parsers =リスト(e1、e2).map(backTrackingParser0)
         (s:LemmaStream)=>
           val res = parsers.foldLeft(成功[リスト[U]](Nil、s):ParseResult [リスト[U]])(_。next(_))
           res.map {lst => val list = lst.reverse;  (list.head、list.tail.head).asInstanceOf [U]}
       case Transformed(innerExpression:Expression [_、_]、t)=>
         val innerParser = backTrackingParser0(innerExpression)
         val converter = defaultSequencerHandler(t)
         (s:LemmaStream)=>
           innerParser(s).map(コンバーター)
       case _ => throw new IllegalArgumentException(s "backTrackingParser0は式$ eに対して実装されていません")
     }
     uncheckedGenerics(backTrackingParser0(e))
   }


返されたパーサーは、入力補題ストリームを受け取ります。 通常の文字列を解析するには、このメソッドを使用して文字列パーサーに変換する必要があります。



  暗黙的なdef toSimpleParser [T](p:パーサー[T]):SimpleParser [T] =(text:String)=> {
     val res = p(text.split( "").map(wordToLemma))
     if(res.tail.nonEmpty)
      新しいIllegalArgumentExceptionをスロー(s "\" $ text \ ""を解析できません)
     res.value
   }


このようなパーサーは、すでに数字を解析するために直接使用できます。



	 assert(parse( "2700万3千24 5")=== 27003245L)




テキストジェネレーターを設計する



同じルールを使用してテキストを生成するには、ルールをテキストジェネレーターに変換する必要があります。 繰り返しになりますが、最終テキストではなく補題のストリームを生成し、単語の形態学的形式(調整、制御)を選択するためのルールを考慮して、このストリームから適切な単語を選択します。



ジェネレーターは、あるタイプのUの値を補題のチェーンに変換する関数です。 構文解析式は、パターンマッチングを使用してジェネレーターに変換されます。



   def constructGenerator [U](tGen:TransformerGenerator [U]、selGen:BooleanSelectorGenerator [U])(e:式[LemmaStream、U]):ジェネレーター[U] = {
    暗黙的なdef uncheckedGenerics [T [_]、O](t:T [_]):T [O] = t.asInstanceOf [T [O]]
    暗黙的なdef uncheckedGenerics2 [T [_、_]、O、P](t:T [_、_]):T [O、P] = t.asInstanceOf [T [O、P]]
     def constructGenerator0(e:Expression [_、_]):Generator [Any] = e match {
       case ConstExpression(l:LemmaStream、u)=>(t)=> l
      ラベル付きケース(_、e1)=> constructGenerator0(e1)
       case Epsilon(_)=>(t)=> Iterable()
      ケースペア(e1、e2)=>
         val g1 = constructGenerator0(e1)
         val g2 = constructGenerator0(e2)
         (u:(_、_))=> g1(u._1)++ g2(u._2)
       case BooleanAlternative(sel:SemanticSelector [U]、e1、e2)=>
         val selector = selGen(sel).asInstanceOf [Any =>ブール]
         val g1 = constructGenerator0(e1)
         val g2 = constructGenerator0(e2)
         (u)=>
           if(セレクター(u))
             g2(u)
          他に
             g1(u)
      ケースMapAlternative(lst)=>
         val map = lst.map(c =>(c.upper、c.lower))。toMap:Map [A​​ny、LemmaStream]
         (u)=>
           map.getOrElse(u、新しいIllegalArgumentExceptionをスローします(s「$ eで$ uのテキストを生成できません」))
       case Transformed(innerExpression、t)=>
         val innerGen = constructGenerator0(innerExpression)
         valトランスフォーマー= tGen(t)
         (u:U)=> innerGen(トランス(u))
       case _ => throw new IllegalArgumentException(s "constructGeneratorは式$ eに対して実装されていません")
     }
     constructGenerator0(e)
   }




単語形式の選択



数字を構成する単語の形式を選択するには、現在の単語(存在する場合)の左側の単語と右側の単語で構成されるコンテキストを使用するだけで十分です。 単語選択ルールの署名:



	 def betterForm(補題:LemmaInfo、左:オプション[LemmaInfo]、右:オプション[LemmaInfo]):WordFormAssociation


ルール自体は、トリプルに対するパターンマッチングを使用して設定されます-現在の補題、左側のコンテキスト、右側のコンテキスト。



おわりに



文法の宣言的表現により、次のような正式な言語構造を記述することができます。





宣言的記述に基づいて、テキストをセマンティクスおよびセマンティクスに自然言語テキストに直接変換するパーサーおよびジェネレーターが構築されます。



記事の PS コード



All Articles