2013年10月7日 星期一

[文字雲] 中文分詞的方法有哪些?

完全是誤打誤撞。

原先我的研究是依照某人喜愛的文章的標籤,推斷他有可能喜愛的文章。而其中,標籤的給定,是所謂的工人智慧,也就是使用者自行貼上所認定的標籤文字。前述的標籤文字,也就是在圖書領域或自然語言領域所謂的關鍵字。

原本我對於關鍵字是沒有需要去研究的,在工人智慧標籤的領域中,這是人的工作。但是在標籤的領域中,的確有著由電腦分析文章後給予關鍵字當做標籤的系統存在。也因此當我不小心誤入這個網站(http://blog.timc.idv.tw/posts/wordcloud/),我以為,我就可以提供自動給予標籤建議的功能,這在一個標籤系統中,是有效降低使用者心智負擔,提高使用者經驗的一種做法。

其中,文章詞頻分析,該「文字雲」的做法是用標準的 N-gram。因為中文很開心的是詞性變化與字無關,同時使用上,名詞、動詞、形容詞…等的字數也不會太長。我很開心的就拿來用了。當然我先把他的程式改寫成 python。我把我手邊有的財經資料文章試跑看看,我取到 8-gram,結果得到的是以下的結果,看來切的不是很好。

 

公司,839
新細明體,819
細明體,685
不適用,673
仟元,512
報導,507
億元,492
證交所重大訊息公,456
交所重大訊息公告,456
記者,447
精實新聞,447
事實發生日,286
本公司,197
美元,189
公司名稱,187
其他應敘明事項,174
發生緣由,166
與公司關係,163
個月,163
億美元,158
相互持股比例,153
不過,133
輸入本公司或子公,132
請輸入本公司或子,132
入本公司或子公司,132
科技股份有限公司,128

我很好奇,大家的自動詞頻統計的中文分詞是怎麼做到的?拜請 google 大神,得知台灣目前似乎只有中研院有公開研究成果可看(http://ckipsvr.iis.sinica.edu.tw/),另外有的是拿別人已經做好的詞庫或自己調整過的詞庫做比對。除了台灣的研究,在 google 找到的多是大陸的研究,而我的入門磚選擇的是一篇論文「中文分詞算法概述」(連結)。(我隱約地感覺到,台灣叫斷詞,大陸叫分詞。我不太確定兩邊的專有名詞用的是哪一個)。我最愛的就是這一種論文,它可以快速了解目前該領域的現況,對於初學者最好了!

一個題外話,文章中引言的第一句:

自然语言处理是人工智能的一个重要分支。中文分词是中文自然语言处理的一项基础性工作,也是中文信息处理的一个重要问题。

在我認為中文分詞若是採用詞庫為基礎,建詞庫除了用專人建出來之外,大可以使用搜尋引擎的輸入關鍵字或標籤系統的標籤來組合成詞庫,這是利用工人智慧的好範例。中文分詞是一個基礎工作,很可惜的這種基礎工作現在台灣很難 google 到(不在前幾頁)。

回主題,看完該論文之後快速整理中文分詞的做法如下:

  1. 基於字串比配的分詞方法:這種方法又叫「機械分詞方法」、「基於字典的分詞方法」。它是按照一定的策略將待分析的字串與足夠大的詞典進行比對。若在詞典中找到某個字串,就算比對成功(識別出一個詞)。這個方法有三個要素(1)分詞詞典、(2)文本掃描順序、(3)比對原則。文本掃描順序有:(1)正向順描、(2)逆向順描、(3)雙向掃描。比對原則有:(1)最大比對、(2)最小比對、(3)逐詞比對、(4)最佳比對。依照三個要素的排列組合,常常配對使用的方法是:(1)最大比對法(MM)、(2)逆向最大比對法(RMM)、(3)逐詞遍歷法、(4)設立切分標誌法、(5)最佳比對法。
  2. 基於理解的分詞方法:這種方法又叫「基於人工智慧的分詞方法」。在分詞的同時進行句法分析、語義分析,利用句法訊息和語義訊息處理岐義現象。通常包括三個部份:(1)分詞子系統、(2)句法語義子系統、(3)總控部份。基於理解的分詞方法主要有幾種實作:(1)專家系統分詞法、(2)神經網路分詞法、(3)神經網路專家系統整合式分詞法。
  3. 基於統計的分詞方法:這種方法的立論精神是:詞是穩定的組合,因此在文章中,相鄰的字同時出現的次數越高,越有可能構成一個詞。因此字與字相鄰出現的頻率可以反映成詞的可信度。可以從訓練的文章中,對於相鄰出現的字的組合的頻率進行統計,以決定是否為一個詞。這種方法又叫做無字典分詞法。這方法使用的統計模型有:(1)N元文法模型(N-gram)、(2)隱 Markov 模型、(3)最大熵模型。在實際應用上,常和「基於字典的分詞方法」結合,以獲得比對分詞法的高效率及無詞典分詞的可辨識生詞的優點。
  4. 基於語義的分詞方法:這個方法引入語義分析,對自然語言本身的語言訊息進行更多處理。如(1)擴充轉移網路法、(2)矩陣約束法。(這個方法在原文中說明就比較少,以致我看不太懂。第2大種的子項目中也有語義分析,不曉得指的是不是同一件事?)

根據以上的分類法,文字雲用的是 「3. 基於統計的分詞方法」中的 N-gram 方法。如果是拿以前的標籤系統,中文分詞不會有問題,因為整個系統的標籤就可以當做字典,拿來對文章做 N-gram 加上字典法,應該就很夠用了。但是,目前有收錄中文的標籤系統且資料(我只要標籤)有公開的,沒有!只好學文字雲的作者一下,自己用 N-gram 再加工。選擇 N-gram 只是因為隱 Markov 模型、最大熵模型我不曉得要怎麼把原理轉成實作。若有大德提點,則是非常感激不盡。

2013年10月1日 星期二

[vb.net] 變數初始值的指定

因為在 C# 用習慣了,換到 vb.net 有點卡卡的,原先用 vb6 又沒有這種語法。

基本型別是沒有什麼問題。

常常會忘記的就是物件的初始值指定、集合物件(collection, dictionary) 的內容值指定這兩件事。

所以寫下來備忘。

對於物件的初始值指定,是使用 With 關鍵字。在大括號裡給予初始值。

Class Apple
    Public Place As String
    Public weight As Integer
End Class

Dim a As Apple = New Apple With {.Place = "Taiwan", .weight = 1}

對於集合物件,則是使用 From 關鍵字。在大括號裡給予初始值。這東西要 VisualStudio 2010 之後才有支援(又多了一個使用 C# 的理由)。太長可以換行。

Dim PostLabel As Dictionary(Of String, String) = New Dictionary(Of String, String) From
    {
        {"Customer", "Forest"},
        {"Address", "Cali"},
        {"Code", "302"}
    }

Dim Box As Collection = New Collection From {"Apple", 2, {"Made", "HC, Taiwan"}, PostLabel, a}

不過,寫到這,我有的疑問,key / value pair 是用大括號加逗點,list 也是。

一下子,上面的 collection,不曉得 {"Made", "HC, Taiwan"} 這一項是怎麼解釋?

寫了個小程式測試一下

For Each obj In Box
    Console.WriteLine("Type:{0}", obj.GetType().Name)
Next

得到的結果是

Type:String
Type:Int32
Type:String
Type:Dictionary`2
Type:Apple

嗯,得到了一個 String 的型別?!

如果把該項換成 {"Made", "HC, Taiwan", "XX"},則會得到「型別初始設定式發生例外狀況」的錯誤。所以要記得這個特性才行。自己不要誤以為是正確的。

若是想要得到一個 string array,多出一組大括號,像是這樣

Dim Box As Collection = New Collection From {"Apple", 2, {{"Made", "HC Taiwan"}}, PostLabel, a}

得到的結果是

Type:String
Type:Int32
Type:String[]
Type:Dictionary`2
Type:Apple

但我想試著弄出 dictionary 來,試寫成

Dim Box As Collection = New Collection From {"Apple", 2, {{"Made", "HC Taiwan"}, {"Make", "TP Taiwan"}}, PostLabel, a}

得到錯誤

型別 '1-維陣列屬於 String' 的值無法轉換成 'String'。

所以目前還沒辦法隨心所欲呢。

[named pipe] 訊息傳送的協定 - 使用訊息長度

上次討論的是,使用分隔字元的訊息傳送協定的實作方式及需要注意的事項(http://www.dotblogs.com.tw/rickyteng/archive/2013/09/12/118266.aspx)(這一句好長!)。基本上使用分隔字元的方式,通常是用在文字訊息。因為把給人看的文字訊息中,比較不會使用到所謂控制字元(也就是那些看不到的字元),所以拿來用就比較不會有衝突。然而,若是傳送的是位元檔的內容,每一個 byte 可能的值都是 0~255,可以拿來當做分隔字元的 byte 等於是不存在。因此而這次要討論的是使用訊息長度為主的協定,通常就用來傳送非文字訊息。

使用訊息長度,意思就是每一次傳送,告訴對方要傳幾個位元組的資料。為了確保傳遞訊息長度的資訊一定送達,傳遞訊息長度的資料不能太長,而且最好是放在一開頭,這樣可以降低發生錯誤的機會。若是一開始訊息長度就失誤了,後面的訊息都會解譯錯誤。

使用訊息長度,最簡單的實作就是每次傳送的開頭 4 個位元組就是訊息長度。這次我們就來實作這種最簡易協定。

首先先改一下之前的範例,把 StreamReader / StreamWriter 這兩個方便的類別拿掉。

我們回歸到 NamedPipeServer / NamedPipeClient 原先提供的 Read / Write 方法。

Write 方法的改寫非常直覺,先把資料的 byte 陣列準備好,計算出所需長度。把長度轉換成 4 個位元組的表示,先寫出,再接著再將資料陣列寫出。

Sub SubHandleWrite(ByVal content As Byte())
    Dim Buffer As Byte()
    Dim BufferSize As Byte()
    ReDim Buffer(content.Length - 1)
    Array.Copy(content, Buffer, content.Length)
    BufferSize = BitConverter.GetBytes(Buffer.Length)
    PipeServerOut.Write(BufferSize, 0, 4)
    PipeServerOut.Write(Buffer, 0, Buffer.Length)
    TextBox3.Text = ""
    PipeServerOut.Flush()
End Sub

Private Sub Button2_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles Button2.Click
    Try
        Dim ContentByte As Byte()
        ContentByte = System.Text.UTF8Encoding.UTF8.GetBytes(TextBox3.Text)
        SubHandleWrite(ContentByte)
    Catch ex As Exception
        CleanPipeServer()
        CleanThread()
        UpdateConnectionState(ConnectionState.Disconnected)
    End Try
End Sub

看一下 Read 方法的介面定義:

Public Overrides Function Read(ByVal buffer() As Byte, ByVal offset As Integer, ByVal count As Integer) As Integer

Read 方法回傳的值是讀取到的實際長度,傳入的參數則有預計要讀取的長度,用來放資料的陣列(buffer)。

對於接收方,讀到 byte 陣列之後,會要先判斷是資料或是長度資訊。雖然我們知道所有的訊息前 4 個位元組是長度資訊,但是每次 Read 方法都要先決定 buffer 的長度才能呼叫 Read。想想有點雞生蛋、雞生雞的問題。而大多數的人採用兩種策略來應付:

  1. 永遠先讀 4 個位元組,得知長度後,再準備足夠的 buffer,讀取訊息內容。若訊息內容較長,可以分次讀取。
  2. 永遠讀一定長度的位元組,再從最前面 4 個位元組算出長度,再算出需要讀取的次數,分次讀取。

Read 方法是阻塞式的跟之前的 ReadLine 一樣,行為就是讀到東西時,會馬上回傳,若讀不到東西時,會 Block 住,也就是卡住。Read 方法會回傳的另一種情況是 pipe 斷開。所以,此時要判斷是否為讀到東西而回傳,就是看 Read 方法的回傳值是否為 0。若是 Read 方法的回傳值為 0 就是 pipe 斷開;若是 Read 方法的回傳值不為 0,就是有讀到東西。

上述兩種策略說來都是一句,但是實際狀況可不是這麼簡單。問題就在讀取到的實際長度!最簡單的例子就是,你讀到的只有 2 個 byte 怎辦?來吧,讓我們用第一個策略來實作,捲起袖子見招拆招吧。

While True
    ' Read Length Step
    ReadCount = PipeServerIn.Read(ReadBuffer, 0, 4)
    Select Case ReadCount
        Case 0
            Exit While
        Case Is < 4
            ' Need Handle
        Case 4
            MsgLength = BitConverter.ToInt32(ReadBuffer, 0)
            While True
                ' Read Message Body
            End While
        Case Else
            ' God!
    End Select
End While

先讀取 4 個 byte,然後看看讀取到的數量,如果是 0,那就是斷掉了。如果小於 4,那就要另外處理(等下說明)。如果等於 4,就轉成訊息長度,然後進入接收訊息本體迴圈。如果都不是,那就看到神了!

讀到的數量小於 4,那就要等下一次讀取,所以要把這次讀到的保留下來吧!所以改寫成下面的樣子:

While True
    ' Read Length Step
    ReadCount = PipeServerIn.Read(ReadBuffer, 0, 4)
    Select Case ReadCount
        Case 0
            Exit While
        Case Is < 4
            Dim TmpByte As Byte()
            ReDim TmpByte(ReadCount - 1)
            Array.Copy(ReadBuffer, TmpByte, ReadCount)
            MsgLengthTmp.AddRange(TmpByte)
        Case 4
            MsgLength = BitConverter.ToInt32(ReadBuffer, 0)
            While True
                ' Read Message Body
            End While
        Case Else
            ' God!
    End Select
End While

眼尖的你有發現問題嗎?

當把這次讀到的保留下來後,讓程式做第二次讀取時,還是要讀 4 個位元組嗎?當然不是!所以就連在讀取訊息長度的階段 Read 方法,不是每次都讀 4 個位元組可以解決的。讓我們來修這個問題吧。所以就要改成下面的樣子:

Dim ReadBuffer As Byte()
ReDim ReadBuffer(3)
Dim MsgLengthTmp As List(Of Byte) = New List(Of Byte)
Dim TmpByte As Byte()

While True
    ' Read Length Step
    ReadCount = PipeServerIn.Read(ReadBuffer, 0, 4 - MsgLengthTmp.Count)
    If ReadCount = 0 Then
        Exit While
    End If
    ReDim TmpByte(ReadCount - 1)
    Array.Copy(ReadBuffer, TmpByte, ReadCount)
    MsgLengthTmp.AddRange(TmpByte)

    Select Case MsgLengthTmp.Count
        Case Is < 4
            ' Pass
        Case 4
            MsgLength = BitConverter.ToInt32(MsgLengthTmp.ToArray(), 0)
            MsgLengthTmp.Clear()
            While True
                ' Read Message Body
            End While
    End Select
End While

接下來要處理接受訊息本體,其考量與讀取訊息長度階段差不多。BUT,要多考量一個 buffer 長度的考量。因為訊息本體最長長度目前受到我們 4 個位元組的限制(Int32),只能有 256^4 / 2 = 2147483648 這麼長。一次讀取到的數量不一定是全部。所以分段接收還是要考慮進來。所以經過接收的部份如下:

MsgLength = BitConverter.ToInt32(MsgLengthTmp.ToArray(), 0)
MsgLengthTmp.Clear()
Dim ReadMsgBuffer As Byte()
Dim ReadMsgBufferSize As Integer = MsgLength
ReDim ReadMsgBuffer(ReadMsgBufferSize - 1)
Dim Offset As Integer = 0
While True
    ReadCount = PipeServerIn.Read(ReadMsgBuffer, Offset, MsgLength - Offset)
    UpdateTextBox2State(String.Format("Read {0} bytes", ReadCount))
    UpdateTextBox2State(BitConverter.ToString(ReadMsgBuffer, Offset, ReadCount))
    Offset += ReadCount
    If Offset = MsgLength Then
        UpdateTextBox2State("Recv a whole Msg")
        UpdateTextBox2State(System.Text.UTF8Encoding.UTF8.GetString(ReadMsgBuffer))
        Exit While
    End If
End While

目前這個程式是可以傳送文字,接收到之後更新在畫面上。

若是要傳送檔案,要怎麼修改。就留給大家吧。

image image