tn是desert(沙漠之鷹)和tan共同開發的一種用於匹配,轉寫和抽取文本的語言(DSL)。並為其開發和優化了專用的編譯器。基於遞歸下降方法和正則表達式,能解析自然文本並轉換為樹和字典,識別時間,地址,數量等復雜序列模式。
github地址:https://github.com/ferventdesert/tnpy
0.設計理由
字符串分析和處理幾乎是每個員程序必備的工作,簡單到分割類似"1,2,3,4"這樣的字符串,稍微復雜一些如字符串匹配,再復雜如編譯和分析SQL語法。字符串幾乎具有無窮的表達能力,解決字符串問題,就解決了計算機90%的問題。
雖然字符串處理如此深入人心,但當分割字符時,本來都是按照逗號分割的,突然出現分號,程序就可能出錯。再如日期處理,每個程序員肯定都對各種奇怪詭異的時間表達方式感到頭疼,處理起來非常費時。這些功能,幾乎只能以硬編碼實現。它們是與外界交互的最底層模塊,然而卻如此脆弱。
- 如何將”一百二十三“轉換為數字?
- 如何將”2013年12月14日“識別為時間並轉換為時間類型?
- 如何分析一個XML或JSON文件?
正則表達式雖提供了強大的匹配功能,成為必備的工具,但它有不少局限,我們擴展了正則表達式引擎,使之能力大大增強。
在線演示:http://www.desertlambda.com:81/extracttext.html
1. 如何學習?
基本上程序員都讀過“30分鍾學會正則表達式”這篇文章吧?最后沒幾個人能在30分鍾內就讀完它。不過相信我,TN引擎只需要15分鍾就可以學會。
詳細的語法說明在這里:
[tn基本語法][1]
[使用tn構造自然語言計算器][2]
[tn實現的xml解析器][3]
TN可以實現文本的匹配,轉寫和信息抽取,可以理解為模板引擎的逆運算。簡單的操作用正則表達式更方便,但不少問題是正則無法解決的。這時就需要使用TN了。
TN的解釋器有Python,C#和C三種版本。C#版本已經不再維護。使用C#或Java等語言的,建議使用IronPython或Jython進行跨語言編譯。
tnpy是tn的Python解釋器,Python良好的可讀性讓代碼寫起來非常方便,代碼不超過1000行,單文件,無第三方庫依賴。推薦使用Python3。
tn是解釋型語言,需要編寫規則文件,並使用tnpy加載,再對文本進行處理。
1. 基礎的匹配和替換:
首先我們先編寫一個最簡單的規則文件learn,內容如下:
#%Order% 1
hello= ("你好");
接着,執行下面的python代碼:
from src.tnpy import RegexCore
core = RegexCore('../rules/learn')
matchs=core.Match('領導你好!老婆你好');
for m in matchs:
print('match',m.mstr, 'pos:',m.pos)
引入tnpy命名空間,之后從learn規則文件初始化引擎,匹配該文本:
success load tn rules:../rules/learn
match 你好 pos: 2
match 你好 pos: 7
上面輸出了文本的匹配結果和位置。當然這一點正則也能做到。
如果我們匹配的是領導你好,老婆您好
,並想把所有的你好
和您好
,都轉寫為hello
。
為此我們添加hello2和hello3兩個子規則:
hello2= $(hello)| ("您好");
#%Order% 1
hello3= $(hello2) : (//:/hello/);
hello2
引用了剛才的hello
規則,同時添加了“您好”
。
hello3是主規則,負責將將hello2
匹配的內容都轉寫為hello
($代表引用一條規則,|表示將幾個規則並列排列,匹配最長的那個規則,:代表轉寫。)
執行下面的代碼:`
print(core.Rewrite('領導你好!老婆您好'));
結果為:
領導hello!老婆hello
如果我們想替換順序,把“你好”放在前面呢?可以這樣寫:
people= ("老婆") | ("領導");
#%Order% 1
reorder= $(people) $(hello3) : $2 $1;
先用people
定義如何描述老婆,領導
,然后用reorder來修改順序, 注意reorder是個順序結構,people匹配老婆和領導,hello3匹配您好/你好,並將其轉換為hello
。 $2和$1
修改了轉寫順序,執行Rewrite后輸出:
hello領導!hello老婆
我們把類似$(name1) $(name2)
的結構,稱為順序表達式,把$(name1) | $(name2)
稱為或表達式。
如果將剛才所有的規則繪制成圖,則是下面的樣子:
![foo.png-34.5kB][4]
2. 正則表達式
僅僅使用文本,表現力太差了。我們引入正則表達式來完成,正則表達式需要放在(//)中,注意和文本("")的區別。
如果要進行轉寫,則標注為(/match/:/rewrite/)
;
下面的表達式將所有的長空白符轉換為一個空白符:
byte_det_space = (/ */://);
下面將所有字母轉換為空白:
low_letter_to_null = (/[a-z]/ ://);
#或者下面:
low_letter= (/[a-z]/);
translate= $(low_letter) : ("");
覺得沒有挑戰?我們接着看下面的。
3. 復雜組合:中文數字轉阿拉伯數字
二十三如何轉換為23?這種用普通的編程會比較困難。我們嘗試用TN解決,會發現一點都不難。
先定義漢字的一二三到九轉換為1-9,你肯定會寫出這樣的規則:
#定義0-9
int_1 = ("一" : "1");
int_0 =("零" : "0");
int_2 = ("二" : "2") | ("兩" : "2");
int_3_9 = ("三" : "3") | ("四" : "4") | ("五" : "5") | ("六" : "6") | ("七" : "7") | ("八" : "8") | ("九" : "9");
int_1_9 = $(int_1) | $(int_2) | $(int_3_9) | (/\d/);
int_0_9 = $(int_0) | $(int_1_9);
int_del_0 = (/零/ : /0/) | (// : /0/);
int_0_9_null = $(int_del_0) | $(int_0_9);
之所以要把0,1,2分開寫,是因為這些數有特殊情況,如兩和二都代表2,需要在后面特殊處理。
上面的int_0_9_null
規則,就可以把五七零二
轉寫為5702
。但沒法處理二十三
這樣的情況。
再定義下面的規則,這樣一十三
可以轉寫為13
int_del_0 = (/零/ : /0/) | (// : /0/);
int_0_9_null = $(int_del_0) | $(int_0_9);
#定義10,十
int_1_decades = (/十/ : /1/) | (/一十/ : /1/);
再加上下面的規則,int_1_9_decades定義了十位數如何轉寫,而int_10_99定義了從十到九十九的轉寫規則。
int_10_99 = $(int_1_9_decades) $(int_0_9_null) | (/[1-9][0-9]/) ;
int_1_99 = $(int_1_9) | $(int_10_99) ;
int_01_99 = $(int_1_9) | $(int_10_99) | (/\d{1,2}/);
#%Order% 3
int_0_99 = $(int_0) | $(int_1_9) | $(int_10_99);
看看下面的例子:
print({r:core.Rewrite(r) for r in ['十','三十七','一十三','68']});
運行結果:
{'一十三': '13', '68': '68', '十': '10', '三十七': '37'}
是不是感到很神奇?三十七是如何被轉寫為37的?
仔細看規則,規則自底向上構造成了一棵規則樹,in_0_99是整棵樹的根節點。結構如下圖:
![foo.png-132.1kB][5]
下面的log文件給出了匹配過程:
int_0_99,Table,Raw =三十七
int_0,String,Raw =三十七
int_0,String,NG
int_1_9,Table,Raw =三十七
int_1,String,Raw =三十七
int_1,String,NG
int_2,Table,Raw =三十七
int_2_merge,Regex,Raw =三十七
int_2_merge,Regex,NG
int_2,Table,NG
int_3_9,Table,Raw =三十七
int_3_9_merge,Regex,Raw =三十七
int_3_9_merge,Regex,Match=三
int_3_9,Table,Match=三
int_1_9_3,Regex,Raw =三十七
int_1_9_3,Regex,NG
int_1_9,Table,Match=三
int_10_99,Table,Raw =三十七
int_10_99_0,Sequence,Raw =三十七
int_1_9_decades,Table,Raw =三十七
int_1_decades,Table,Raw =三十七
int_1_decades_0,Regex,Raw =三十七
int_1_decades_0,Regex,Match=十
int_1_decades_1,Regex,Raw =三十七
int_1_decades_1,Regex,NG
int_1_decades,Table,Match=十
int_1_9_decades_1,Sequence,Raw =三十七
int_1_9,Table,Raw =三十七
int_1_9,Table,Buff =三
unknown,Regex,Raw =十七
unknown,Regex,Match=十
int_1_9_decades_1,Sequence,Match=三十
int_1_9_decades,Table,Match=三十
int_0_9_null,Table,Raw =七
int_del_0,Table,Raw =七
int_del_0_0,Regex,Raw =七
int_del_0_0,Regex,NG
int_del_0_1,Regex,Raw =七
int_del_0_1,Regex,Match=
int_del_0,Table,Match=
int_0_9,Table,Raw =七
int_0,String,Raw =七
int_0,String,NG
int_1_9,Table,Raw =七
int_1,String,Raw =七
int_1,String,NG
int_2,Table,Raw =七
int_2_merge,Regex,Raw =七
int_2_merge,Regex,NG
int_2,Table,NG
int_3_9,Table,Raw =七
int_3_9_merge,Regex,Raw =七
int_3_9_merge,Regex,Match=七
int_3_9,Table,Match=七
int_1_9_3,Regex,Raw =七
int_1_9_3,Regex,NG
int_1_9,Table,Match=七
int_0_9,Table,Match=七
int_0_9_null,Table,Match=七
int_10_99_0,Sequence,Match=三十七
int_10_99_1,Regex,Raw =三十七
int_10_99_1,Regex,NG
int_10_99,Table,Match=三十七
int_0_99,Table,Match=三十七
引擎從文本的左向右,沿着規則樹尋找最長的文本,如果在一個順序表達式上的任何一步失敗,那么整個順序表達式被拋棄。或表達式會遍歷每個子表達式,直到發現最長的那個,返回結果。具體的匹配原理,以及優化,會在專門的文章中介紹。
4. 由規則構造更復雜的規則
自然而然的,知道怎么定義三十七,就可以定義五百三十七,那不過是int_1_9_hundreds+int_0_99
(這個已經定義過了)。
int_1_9_hundreds = $(int_1_9) ("百" : "");
int_100_999 = $(int_1_9_hundreds) ("" : "00") | $(int_1_9_hundreds) $(int_10_99);
int_1_999 = $(int_1_99) | $(int_100_999);
int_1_999
可以處理類似五百三十七這樣的問題!
進而,我們可以處理幾千,幾萬,這個延伸到萬以后,就可以自然而然地衍生出億,萬億的表達。
如何處理負數?這還不簡單!
signed_symbol0 = ("正" : "") | ("負" : "-") | ("正負" : "±") | ("\+" : "+") | ("\-" : "-") | ("±" : "±") ;
signed_symbol = $(signed_symbol0) | $(null_2_null);
接下來,我們默認正整數為integer_int
,那么,整數(包含正負)就是:
integer_signed = $(signed_symbol) $(integer_int)
5. 屬性提取
沿着剛才的路,我們自然而然地能定義分數,但僅僅是轉寫還不夠,遇到三分之一,我們不僅要將其處理為1/3,還要計算出它的值,這就涉及到屬性抽取。也就是把信息從文本中提取為字典。
分數,不過是整數+分之+整數
,可以定義成下面的形式:
fraction_cnv_slash = ("分之" : "/");
fraction2 = ("/" : "/");
percent_transform= ("%" : "100") | ("‰" : "1000");
#%Type% DOUBLE
#%Property% Denominator,,Numerator| Numerator ,, Denominator | Denominator ,, Numerator
#%Order% 101
fraction = $(integer_int_extend) $(fraction_cnv_slash) $(integer_int) : $3 $2 $1
| $(integer_int) $(fraction2) $(integer_int)
| $(pure_decimal) ("" : "/") $(percent_transform);
這個有點復雜,但容我慢慢講解。分數有三種情況,如剛才的三分之一
,或是1/3
,或是30%
。分別對應上面fraction
規則的三個子規則。仔細地看上面的規則,不難理解。
值得注意的是Property這個標簽,該標簽定義了如何抽取信息。也是用豎線分隔,每個名稱對應下面的一個子規則,為空的直接跳過。那么”十三分之二十四“中,“十三”就對應Numerator, 而“二十四”對應Denominator。來測試一下:
print(core.Extract('十三分之二十四',entities=[core.Entities['fraction']]))
我們用Extract函數來抽取文本,返回的是一個字典,entites是可選參數,我們限制只用fraction規則來匹配,獲得輸出:
'#match': '十三分之二十四', 'Denominator': '13', '#pos': 3}]```
是不是很贊?
###6.嵌入Python腳本
有一種需求還沒談到,將所有的大寫字母轉換為小寫字母,你可能會想定義26個字符串規則,並用或表達式來拼接起來吧?這樣太費事了。我們可以直接這樣:
`low_to_up_letter = (/[A-Z]/) : "str.lower(mt)";`
`[A-Z]`匹配了所有的大寫字母,將匹配結果送到后半段的轉寫,內置的解釋器會執行那段python代碼,將其轉換為小寫,mt代表前面表達式的匹配串,rt代表轉寫串。好在`[A-Z]`不執行轉寫,可以認為`mt==rt`.
這是在轉寫過程中嵌入python的例子,還能在匹配時嵌入轉寫:
`foo = "findsecret" : "print(mt)"`;
前面的findsecret函數負責在字符串中找到“神秘文本”,后面的轉寫代碼打印出來,並將原始的字符返回…
##6. 你在15分鍾內讀完了么?
我相信你沒有,因為讀懂那個匹配規則的日志文件,就需要最少五分鍾,但如果你有編譯原理和正則基礎的話,還是能很快理解的。而從零開發這個引擎,到反復優化和完善,花了一年之久。
定義了各種數字之后,我們就能很快地定義時間,日期,電話號碼,地址…而你看到的只是TN語言的冰山一角。
- 它能夠分析文本的模式,解析諸如ABCABC這樣的序列,從而發現這是一個重復模式。
- 不僅能夠順序匹配,還能逆向,甚至亂序匹配,這就能夠抽取類似“學校的校訓”這樣的問題。
- 規則可以調用自身,配合腳本,因此能夠實現遞歸下降解析。例如30行代碼實現xml解析,或20行規則實現自然語言計算器。
- 規則可以嵌入腳本,甚至動態生成代碼,因此,甚至在理論上,TN能夠自己編譯自己。
- TN還能做一個簡單的SQL解釋器,或是中文英文的簡單互相翻譯的工具。
是不是已經激動地顫抖了?唯一限制你能力的就是你的想象力。本博客將會進一步發布一系列有關tn的內容,包括高級語法,tn優化等。
感興趣的可以聯系作者:buptzym@qq.com,或在本文下面留言。
[1]: http://www.cnblogs.com/buptzym/p/5355827.html
[2]: http://www.cnblogs.com/buptzym/p/5361121.html
[3]: http://www.cnblogs.com/buptzym/p/5355920.html
[4]: http://static.zybuluo.com/buptzym/ksl5ggrfcn1psmdf2f81i8wg/foo.png
[5]: http://static.zybuluo.com/buptzym/itwhlmz8ua2h3jgbqdq5z48g/foo.png