1. 程式人生 > >Prolog教程 16--自然語言理解

Prolog教程 16--自然語言理解

Prolog特別適合開發自然語言的應用系統。在這一章,我們將為尋找Nani遊戲新增自然語言理解的部分。(由於Prolog謂詞是使用的英文符號,所以這裡的自然語言理解只能侷限在英文中)

在著手於編制尋找Nani之前, 我們先來開發一個能夠分析簡單英語句子的模組。把這種方法掌握之後,編制尋找Nani的自然語言部分就不在話下了。

下面是兩個簡單的英語句子:

The dog ate the bone.
The big brown mouse chases a lazy cat.

我們可以使用下面的語法規則來描述這種句子。

sentence : (句子)
nounphrase, verbphrase.

nounphrase : (名詞短語)
determiner, nounexpression.
nounphrase : (名詞短語)
nounexpression.
nounexpression :
noun.
nounexpression :
adjective(形容詞), nounexpression.
verbphrase : (動詞短語)
verb, nounphrase.
determiner : (限定詞)
the | a.
noun : (名詞)
dog | bone | mouse | cat.
verb : (動詞)
ate | chases.
adjective :
big | brown | lazy.

稍微解釋一下:第一條規則說明一個句子有一個名詞短語和一個動詞短語構成。最後的一個規則定義了單詞big、brown和lazy是形容詞,中間的“|”表示或者的意思。

首先,來判斷某個句子是否是合法的句子。我們編寫了sentence/1謂詞,它可以判斷它的引數是否是一個句子。

句子必須用Prolog的一種資料結構來表達,這裡使用列表。例如,前面的兩個句子的Prolog表達形式如下:

[the,dog,ate,the,bone]
[the,big,brown,mouse,chases,a,lazy,cat]

分析句子的方法有兩種。第一種是選擇並校樣的方法(見後面的人工智慧例項部分),使用這種方法,首先把句子的可能分解情況找出來,再來測試被分解的每一個部分是否合法。我們前面已經介紹過使用append/3謂詞能夠把列表分成兩個部分。使用這種方法,頂層的規則可以是如下的形式:

sentence(L) :-
append(NP, VP, L),
nounphrase(NP),
verbphrase(VP).

append/3謂詞可以把列表L的所有可能的分解情況窮舉出來,分解後的兩個部分為NP和VP,其後的兩個目標則分別測試NP和VP是否是合法的,如果不是則會產生回溯,從而測試其他的分解情況。

謂詞nounphrase/1和verbphrase/1的編寫方法與sentence/1基本相同,它們呼叫其他的謂詞來判斷句子中的更小的部分是否合法,只到呼叫到定義單詞的謂詞,例如:

verb([ate]).
verb([chases]).

noun([mouse]).
noun([dog]).

差異表

前面的這種方法效率是非常低的,這是因為選擇並校驗的方法需要窮舉所有的情況,更何況在每一層的目標之中都要進行這種測試。

更有效的方法就是跳過選擇的步驟,而直接把整個列表傳到下一級的謂詞中,每個謂詞把自己所要尋找的語法元素找出來,並返回剩下的列表。

為了能夠達到這個目標,我們需要介紹一種新的資料結構:差異表。它由兩個相關的表構成,第一個表稱為全表,而第二個表稱為餘表。這兩個表可以作為謂詞的兩個引數,不過我們通常使用‘-’連線這兩個表,這樣易於閱讀。它的形式是X-Y。

我們使用差異表改寫了第一條語法規則。如果能夠從列表S的頭開始,提取出一個名詞短語,其餘部分S1,並且能夠從S1的頭開始,提取出一個動詞短語,並且其餘部分為空表,那麼列表S是一個句子。(這句話要細心理解,差異表所表示的表是全表和餘表之間的差異。)

sentence(S) :-
nounphrase(S-S1),
verbphrase(S1-[]).

我們先跳過謂詞nounphrase/1和verbphrase/1的編寫,而來看看是如何定義真正的單詞的。這些單詞也必須書寫成差異表的形式,這個很容易做到:如果列表的第一個元素是所需的單詞,那麼餘表就是除去第一個單詞的表。

noun([dog|X]-X).
noun([cat|X]-X).
noun([mouse|X]-X).

verb([ate|X]-X).
verb([chases|X]-X).

adjective([big|X]-X).
adjective([brown|X]-X).
adjective([lazy|X]-X).

determiner([the|X]-X).
determiner([a|X]-X).

下面是兩個簡單的測試,

?- noun([dog,ate,the,bone]-X).
%第一個單詞dog是名詞,於是成功,並且餘表是後面的元素組成的表。
X = [ate,the,bone]

?- verb([dog,ate,the,bone]-X).
no

我們把剩下的一些語法規則寫完:

nounphrase(NP-X):-
determiner(NP-S1),
nounexpression(S1-X).

nounphrase(NP-X):-
nounexpression(NP-X).

nounexpression(NE-X):-
noun(NE-X).

nounexpression(NE-X):-
adjective(NE-S1),
nounexpression(S1-X).

verbphrase(VP-X):-
verb(VP-S1),
nounphrase(S1-X).

注意謂詞nounexpression/1的遞迴定義,這樣就可以處理名詞前面有任意多個形容詞的情況。

我們來用幾個句子測試一下:

?- sentence([the,lazy,mouse,ate,a,dog]).
yes

?- sentence([the,dog,ate]).
no

?- sentence([a,big,brown,cat,chases,a,lazy,brown,dog]).
yes

?- sentence([the,cat,jumps,the,mouse]).
no

下面是單步跟蹤某個句子的情況:

詢問是
?- sentence([dog,chases,cat]).

1-1 CALL sentence([dog,chases,cat])
2-1 CALL nounphrase([dog,chases,cat]-_0)
3-1 CALL determiner([dog,chases,cat]-_0)
3-1 FAIL determiner([dog,chases,cat]-_0)
2-1 REDO nounphrase([dog,chases,cat]-_0)
3-1 CALL nounexpression([dog,chases,cat]- _0)
4-1 CALL noun([dog,chases,cat]-_0)
4-1 EXIT noun([dog,chases,cat]-
[chases,cat])
注意,表示餘表的變數的繫結操作是直到延伸至最底層時才進行的,每一層把它的餘表和上一層的繫結。這樣,當到達了詞彙層時,繫結的值將通過巢狀的呼叫返回。

3-1 EXIT nounexpression([dog,chases,cat]-
[chases,cat])
2-1 EXIT nounphrase([dog,chases,cat]-
[chases,cat])
現在已經找出了名詞短語,下面來測試餘表是否為動詞短語。

2-2 CALL verbphrase([chases,cat]-[])
3-1 CALL verb([chases,cat]-_4)
3-1 EXIT verb([chases,cat]-[cat])
很容易地就找出了動詞,下面尋找最後的動詞短語。

3-2 CALL nounphrase([cat]-[])
4-1 CALL determiner([cat]-[])
4-1 FAIL determiner([cat]-[])
3-2 REDO nounphrase([cat]-[])
4-1 CALL nounexpression([cat]-[])
5-1 CALL noun([cat]-[])
5-1 EXIT noun([cat]-[])
4-1 EXIT nounexpression([cat]-[])
3-2 EXIT nounphrase([cat]-[])
2-2 EXIT verbphrase([chases,cat]-[])
1-1 EXIT sentence([dog,chases,cat])
yes

尋找nani

現在將使用這種分析句法結構的技術,來完成尋找Nani。

我們首先假設已經完成以下的兩個任務。第一,已經完成了把使用者的輸入轉換成列表的工作。第二,我們可是使用列表的形式來表示命令,例如,goto(office)表示成為[goto,office],而look表示成為[look]。

有了這兩個假設,現在的任務就是把使用者的自然語言轉換成為程式能夠理解的命令列表。例如,我們希望程式能夠把[go,to,the,office]轉換成為[goto,office]。

最高層的謂詞叫做command/2,它的形式如下:

command(OutputList, InputList).

最簡單的命令就是隻有一個動詞的命令,例如look、list_possessions和end。我們可以使用下面的子句來識別這種命令:

command([V], InList):- verb(V, InList-[]).

我們使用前面介紹過的方法來定義動詞,不過這次將多加入一個引數,這個引數用來構造返回的標準命令列表。為了使這個程式看上去更有趣,我們讓它能夠識別命令多種表達形式。例如結束遊戲可以輸入:end、quit和good bye。

下面是幾個簡單的測試:

?- command(X,[look]).
X = [look]

?- command(X,[look,around]).
X = [look]

?- command(X,[inventory]).
X = [list_possessions]

?- command(X,[good,bye]).
X = [end]

下面的任務要複雜一些,我們將考慮動賓結構的命令。使用前面介紹過的知識,可以很容易地完成這個任務。不過此處,還希望除了語法以外還能夠識別語義。

例如,goto動詞後面所跟隨的物體必須是一個地方,而其他的謂詞後面的賓語則是個物體。為了完成這個任務,我們引入了另一個引數。

下面是主子句,我們可以看出新的引數是如何工作的。

command([V,O], InList) :-
verb(Object_Type, V, InList-S1),
object(Object_Type, O, S1-[]).

還必須用事實來定義一些新的動詞:

verb(place, goto, [go,to|X]-X).
verb(place, goto, [go|X]-X).
verb(place, goto, [move,to|X]-X).

我們甚至可以識別goto動詞被隱含的情況,即如果玩家僅僅輸入某個房間的名稱,而沒有前面的謂詞。這種情況下列表及其餘表相同。而room/1謂詞則用來檢測列表的元素是否為一個房間,除了房間的名字是兩個單詞的情況。

下面這條規則的意思是:如果我們從列表的頭開始尋找某個動詞,而列表的頭確是一個房間的名稱,那麼就認為找到了動詞goto,並且返回完成的列表,好讓後面的操作找到 goto動詞的賓語。

verb(place, goto, [X|Y]-[X|Y]):- room(X).
verb(place, goto, [dining,room|Y]-[dining,room|Y]).

下面是關於物品的謂詞:

verb(thing, take, [take|X]-X).
verb(thing, drop, [drop|X]-X).
verb(thing, drop, [put|X]-X).
verb(thing, turn_on, [turn,on|X]-X).

有時候,物品前面可能有限定詞,下面的兩個子句考慮的有無限定詞的兩種情況:

object(Type, N, S1-S3) :-
det(S1-S2),
noun(Type, N, S2-S3).
object(Type, N, S1-S2) :-
noun(Type, N, S1-S2).

由於我們處理句子時只需要去掉限定詞,所以就不需要額外的引數。

det([the|X]- X).
det([a|X]-X).
det([an|X]-X).

定義名詞的方法與動詞相似,不過大部分可以使用原來的定義方法,而只有那些兩個單詞以上的名詞才需要特殊的定義方法。位置名詞使用room謂詞定義。

noun(place, R, [R|X]-X):- room®.
noun(place, ‘dining room’, [dining,room|X]-X).

location謂詞和have謂詞所定義的東西是物品,這裡我們又必須把兩個單詞的物品單獨定義。

noun(thing, T, [T|X]-X):- location(T,_).
noun(thing, T, [T|X]-X):- have(T).
noun(thing, ‘washing machine’, [washing,machine|X]-X).

我們可以把對遊戲當前狀態的識別也做到語法中去。例如,我們想做一個可以開關燈的命令,這個命令是turn_on(light),和turn_on(flashlight)相對應。如果玩家輸入turn on the light,我們必須決定這個light是指房間裡的燈還是flashlight。

在這個遊戲中,房間的燈是永遠也打不開的,因為玩家所扮演的角色是一個3歲的小孩,不過她可以開啟手電筒。下面的程式把turn on the light翻譯成turn on light或者turn on flashlight,這樣就能讓後面的程式來進行判斷了。

noun(thing, flashlight, [light|X], X):- have(flashlight).
noun(thing, light, [light|X], X).

下面來全面的測試一下:

?- command(X,[go,to,the,office]).
X = [goto, office]

?- command(X,[go,dining,room]).

X = [goto, ‘dining room’]

?- command(X,[kitchen]).
X = [goto, kitchen]

?- command(X,[take,the,apple]).
X = [take, apple]

?- command(X,[turn,on,the,light]).
X = [turn_on, light]

?- asserta(have(flashlight)), command(X,[turn,on,the,light]).
X = [turn_on, flashlight]

下面的幾個句子不合法:

?- command(X,[go,to,the,desk]).

no

?- command(X,[go,attic]).
no

?- command(X,[drop,an,office]).
no

Definite Clasue Grammar(DCG)

在Prolog中經常用到差異表,因此許多Prolog版本都對差異表有很好的支援,這樣就可以隱去差異表的一些繁瑣複雜之處。這種語法稱為Definite Clasue Grammer(DCG),它看上去和一般的Prolog子句非常相似,只不過把連線符:-替換成為–>,這種表達形式由Prolog翻譯成為普通的差異表形式。

使用DCG,原來的句子謂詞將寫為:

sentence --> nounphrase, verbphrase.

這個句子將被翻譯成一般的使用差異表的Prolog子句,但是這裡不再用“-”隔開,而是變成了兩個引數,上面的這個句子與下面的Prolog子句等價。

sentence(S1, S2):-
nounphrase(S1, S3),
verbphrase(S3, S2).

因此,既是使用DCG形式定義sentence謂詞,我們在呼叫時仍然需要兩個引數。

?- sentence([dog,chases,cat], []).

用DCG來表示詞彙只需要使用一個列表:

noun --> [dog].
verb --> [chases].

這兩個句子被翻譯成:

noun([dog|X], X).
verb([chases|X], X).

就象在本遊戲中所需要的那樣,有時需要額外的引數來返回語法資訊。這個引數只需要簡單地加入就行了,而句中純Prolog則使用{}括起來,這樣DCG分析器就不會翻譯它。遊戲中的複雜的規則將寫成如下的形式:

command([V,O]) -->
verb(Object_Type, V),
object(Object_Type, O).

verb(place, goto) --> [go, to].
verb(thing, take) --> [take].

object(Type, N) --> det, noun(Type, N).
object(Type, N) --> noun(Type, N).

det --> [the].
det --> [a].

noun(place,X) --> [X], {room(X)}.
noun(place,‘dining room’) --> [dining, room].
noun(thing,X) --> [X], {location(X,_)}.

由於DCG自動的取走第一個引數,如果只輸房間名稱,前面的子句就不能起作用,所以我們還要加上一條:

command([goto, Place]) --> noun(place, Place).

讀入句子

讓我們來最後完工吧。最後的工作是把使用者的輸入變成一張表。下面的程式很夠完成這個任務:

% read a line of words from the user

read_list(L) :-
write(’> '),
read_line(CL),
wordlist(L,CL,[]), !.

read_line(L) :-
get0©,
buildlist(C,L).

buildlist(13,[]) :- !.
buildlist(C,[C|X]) :-
get0(C2),
buildlist(C2,X).

wordlist([X|Y]) --> word(X), whitespace, wordlist(Y).
wordlist([X]) --> whitespace, wordlist(X).
wordlist([X]) --> word(X).
wordlist([X]) --> word(X), whitespace.

word(W) --> charlist(X), {name(W,X)}.

charlist([X|Y]) --> chr(X), charlist(Y).
charlist([X]) --> chr(X).

chr(X) --> [X],{X>=48}.

whitespace --> whsp, whitespace.
whitespace --> whsp.

whsp --> [X], {X48}.

它包括兩個部分:首先使用內部謂詞get0/1讀入單個的ASCII字元, ASCII 13代表句子結束。第二部分使用DCG分析字元列表,從而把它轉化為單詞列表,這裡使用了另一個內部謂詞name/2,它把有ASCII字元組成的列表轉化為原子。

另外一部分是把形如[goto,office]的命令,轉化為goto(office),我們使用稱為univ的內部謂詞完成這個工作,使用"=…"表示。它的作用如下,把一個謂詞轉化為了一個列表,或者反過來。

?- pred(arg1,arg2) =… X.
X = [pred, arg1, arg2]

?- pred =… X.
X = [pred]

?- X =… [pred,arg1,arg1].
X = pred(arg1, arg2)

?- X =… [pred].
X = pred

最後我們使用前面的兩個部分做成一個命令迴圈:

get_command© :-
read_list(L),
command(CL,L),
C =… CL, !.

get_command(_) :-
write(‘I don’‘t understand’), nl, fail.

到此為止,我們的Prolog教程就全部結束了,但是你的工作沒有結束,如果想很好地掌握這門語言,還有很漫長的路要走。

非常感謝您閱讀完這個教程,接下來就請進入實戰部分吧。