在 Swift 中使用馬爾可夫鏈生成文字

SwiftGG翻譯組發表於2019-02-26

原文連結:swift.gg/2018/07/23/…
作者:Mike Ash
原文日期:2018-04-28
譯者:Hale
校對:numbbbbb,mmoaay,Cee
定稿:CMB

馬爾可夫鏈可用於快速生成真實但無意義的文字。今天,我將使用這種技術來建立一個基於這篇部落格內容的文字生成器。這個靈感來源於讀者 Jordan Pittman。

馬爾可夫鏈

理論上講,馬爾可夫鏈是一種狀態機,每一個狀態轉換都有一個與之相關的概率。你可以選擇一個起始狀態,然後隨機地轉換成其他狀態,通過轉移概率來加權,直到到達一個終止狀態。

馬爾可夫鏈有著廣泛的應用,但最有趣的是用於文字生成。在本文生成領域,每個狀態是文字的一部分,通常是一個單詞。狀態和轉換是由一些語料庫生成的,然後遍歷整個鏈併為每個狀態輸出單詞來生成文字。這樣生成的文字通常沒有實際意義,因為該鏈不包含足夠的資訊來保留語料庫的任何潛在含義及語法結構,但是缺乏意義本身卻給文字帶來了意料之外的樂趣。

構建演算法

鏈中的節點由 Word 類的例項表示,此類將會為它所表示的單詞儲存一個字串,同時持有一組指向其他單詞的連結。

我們如何表示這一組連結呢?最直接的方法是採用某種計數的集合,它將儲存其他 Word 例項以及在輸入語料庫中轉換次數的計數。不過,從這樣一個集合中隨機選擇一個連結可能會非常棘手。一個簡單的方法是生成一個範圍從 0 到集合元素總計數之間的隨機數,然後遍歷該集合直到取到很多的連結,然後選中你想要的連結。雖然這個方式簡單,但可能比較耗時。另一種方法是預先生成一個陣列,用於儲存陣列中每個連結的累積總數,然後對 0 和總數之間的隨機數進行二分搜尋。這相對來說更繁瑣一些,但執行效率更高。如果你追求更好的方案,你其實可以做更多的預處理,並最終得到一個可以在常量時間內完成查詢的緊湊結構

最終,我決定偷懶使用一種在空間上極其浪費,但在時間上效率很高且易於實現的結構。該結構每個 Word 包含一個後續 Words 的陣列。如果一個連結被指向多次,那麼將會儲存重複的 Words 陣列。在陣列中選擇一個隨機索引,根據索引返回具有適當權重的隨機元素。

Word 類結構如下:

class Word {
   let str: String?
   var links: [Word] = []

   init(str: String?) {
       self.str = str
   }

   func randomNext() -> Word {
       let index = arc4random_uniform(UInt32(links.count))
       return links[Int(index)]
   }
}
複製程式碼

請注意,links 陣列可能會導致大量迴圈引用。為了避免記憶體洩漏,我們需要手動清理那些記憶體。

我們引入 Chain 類,它將管理鏈中所有的 Words

class Chain {
   var words: [String?: Word] = [:]
複製程式碼

deinit 方法中,清除所有的 links 陣列,以消除所有的迴圈引用。

  deinit {
      for word in words.values {
          word.links = []
      }
  }
複製程式碼

如果沒有這一步,許多單詞例項的記憶體都會洩漏。

現在讓我們看看如何將單詞新增到鏈中。add 方法需要一個字串陣列,該陣列中每一個元素都儲存著一個單詞(或呼叫者希望使用的其他任何字串):

  func add(_ words: [String]) {
複製程式碼

如果鏈中沒有單詞,那麼提前返回。

       if words.isEmpty { return }
複製程式碼

我們想要遍歷那些成對的單詞,遍歷規則是第二個元素的第一個單詞緊隨第一個元素後面的單詞。例如,在句子 “Help, I`m being oppressed,” 中,我們要迭代 ("Help", "I`m")("I`m", "being")("being", "oppressed")

實際上,還需要多做一點事情,因為我們需要編碼句子的開頭和結尾。我們將句子的開頭和結尾用 nil 表示,所以我們要迭代的實際序列是 (nil, "Help")("Help", "I`m")("I`m", "being")("being", "oppressed")("oppressed", nil)

為了允許值為 nil , 我們的陣列宣告為 String? 型別,而不是 String 型別。

       let words = words as [String?]
複製程式碼

接下來構造兩個陣列,一個頭部新增 nil,另一個尾部新增 nil。把它們通過 zip 合併在一起生成我們想要的序列:

       let wordPairs = zip([nil] + words, words + [nil])
       for (first, second) in wordPairs {
複製程式碼

對於這一對中的每個單詞,我們使用一個輔助方法來獲取相應的 Word 物件:

           let firstWord = word(first)
           let secondWord = word(second)
複製程式碼

然後把第二個單詞新增到第一個單詞的連結中:

           firstWord.links.append(secondWord)
       }
   }
複製程式碼

Word 輔助方法從 words 字典中提取例項,如果例項不存在就建立一個新例項並將其放入字典中。這樣就不用擔心字串匹配不到單詞:

   func word(_ str: String?) -> Word {
       if let word = words[str] {
           return word
       } else {
           let word = Word(str: str)
           words[str] = word
           return word
       }
   }
複製程式碼

最後生成我們要的單詞序列:

   func generate() -> [String] {
複製程式碼

我們將逐個生成單詞,並將他們儲存在下面的陣列中:

       var result: [String] = []
複製程式碼

這是一個無限迴圈。因為退出條件沒有清晰的對映到迴圈條件,程式碼如下:

       while true {
複製程式碼

result 中獲取最後一個字串構成 Word 例項。這很好地處理了當 result 為空時的初始情況,因為一旦 last 取值為 nil 就表示第一個單詞:

            let currentWord = word(result.last)
複製程式碼

隨機獲取連結的詞:

            let nextWord = currentWord.randomNext()
複製程式碼

如果連結的單詞不是結尾,將其追加到 result 中。如果是結束,則終止迴圈:

            if let str = nextWord.str {
                result.append(str)
            } else {
                break
            }
        }
複製程式碼

返回包含所有單詞的 result

        return result
    }
}
複製程式碼

最後一件事:我們正在使用 String? 作為 words 的鍵型別,但 Optional 不符合 Hashable 協議。下面是一個擴充套件,當它的封裝型別遵循 Hashable 時新增 OptionalHashable 的實現:

extension Optional: Hashable where Wrapped: Hashable {
    public var hashValue: Int {
        switch self {
        case let wrapped?: return wrapped.hashValue
        case .none: return 42
        }
    }
}
複製程式碼

備註:Swift 4.2 中 Optional 型別已預設實現 Hashable 協議

生成輸入資料

以上就是馬爾可夫鏈的結構,下面我們輸入一些真實文字試試看。

我決定從 RSS 提要中提取文字。還有什麼比用我自己部落格全文作為輸入更好的選擇呢?

let feedURL = URL(string: "https://www.mikeash.com/pyblog/rss.py?mode=fulltext")!

RSS 是一種 XML 格式,所以我們使用 XMLDocument 來解析它:

let xmlDocument = try! XMLDocument(contentsOf: feedURL, options: [])

文章主體被巢狀在 item 節點下的 description 節點。通過 XPath 查詢檢索:

let descriptionNodes = try! xmlDocument.nodes(forXPath: "//item/description")

我們需要 XML 節點中的字串,所以我們從中提取並過濾掉為 nil 的內容。

let descriptionHTMLs = descriptionNodes.compactMap({ $0.stringValue })

我們根本不用關心標籤。NSAttributedString 可以解析 HTML 並生成一個 AttributedString,然後我們可以過濾它:

let descriptionStrings = descriptionHTMLs.map({
   NSAttributedString(html: $0.data(using: .utf8)!, options: [:], documentAttributes: nil)!.string
})
複製程式碼

我們需要一個將字串分解成若干部分的函式。我們的目的是生成 String 陣列,每個陣列對應文字里的一句話。一段文字可能會有很多句話,所以 wordSequences 函式會返回一個 String 的二維陣列:

func wordSequences(in str: String) -> [[String]] {

然後我們將處理結果儲存在一個區域性變數中:

var result: [[String]] = []

將字串分解成句子並不簡單。你可以直接搜尋標點符號,但需要考慮到像 “Mr. Jock, TV quiz Ph.D., bags few lynx.” 這樣的句子,按照標點符號會被分割成四段,但這是一個完整的句子。

NSString 提供了一些智慧檢查字串部分的方法,前提是你需要 import Foundation 。我們會列舉 str 包含的句子,並讓 Foundation 進行處理:

    str.enumerateSubstrings(in: str.startIndex..., options: .bySentences, { substring, substringRange, enclosingRange, stop in
複製程式碼

在將句子拆分成單詞的時候會遇到相似的問題。NSString 也提供了一種用於列舉詞的方法,但是存在一些問題,例如丟失標點符號。我最終決定用一種愚蠢的方式來進行單詞分割,只按空格進行分割。這意味著你最終將包含標點符號的單詞作為字串的一部分。與標點符號被刪除相比,這更多地限制了馬爾可夫鏈,但另一方面,輸出會包含合理的標點符號。我覺得這個折中方案還不錯。

一些換行符會進入資料集,我們首先將這些換行符移除:

        let words = substring!.split(separator: " ").map({
            $0.trimmingCharacters(in: CharacterSet.newlines)
        })
複製程式碼

分割的句子最終被新增到 result 中:

        result.append(words)
    })
複製程式碼

列舉完成後,根據輸入的句子計算出 result ,然後將其返回給呼叫者:

    return result
}
複製程式碼

回到主程式碼。現在已經有辦法將字串轉換為句子列表,我們就可以繼續構建自己的馬爾可夫鏈。首先我們建立一個空的 Chain 物件:

let chain = Chain()

然後我們遍歷所有的字串,提取句子,並將它們新增到鏈中:

for str in descriptionStrings {
   for sentence in wordSequences(in: str) {
       chain.add(sentence)
   }
}
複製程式碼

最後一步當然是生成一些新句子!我們呼叫 generate(),然後用空格連線結果。輸出結果可能命中也可能不命中(考慮到該技術的隨機性,這並不奇怪),所以我們會多生成一些:

for _ in 0 ..< 200 {
   print(""" + chain.generate().joined(separator: " ") + """)
}
複製程式碼

示例輸出

為了演示,下面是這個程式的一些示例輸出:

  • “We`re ready to be small, weak references in New York City.”
  • “It thus makes no values?”
  • “Simple JSON tasks, it`s wasteful if you can be.”
  • “Another problem, but it would make things more programming-related mystery goo.”
  • “The escalating delays after excessive focus on Friday, September 29th.”
  • “You may not set.”
  • “Declare conformance to use = Self.init() to detect the requested values.”
  • “The tagged pointer is inlined at this nature; even hundreds of software and writing out at 64 bits wide.”
  • “We`re ready to express that it works by reader ideas, so the decoding methods for great while, it`s inaccessible to 0xa4, which takes care of increasing addresses as the timing.”
  • “APIs which is mostly a one-sided use it yourself?”
  • “There`s no surprise.”
  • “I wasn`t sure why I`ve been called `zero-cost` in control both take serious effort to miss instead of ARC and games.”
  • “For now, we can look at the filesystem.”
  • “The intent is intended as reader-writer locks.”
  • “For example, we can use of the code?”
  • “Swift`s generics can all fields of Swift programming, with them is no parameters are static subscript, these instantiate self = cluster.reduce(0, +) / Double(cluster.count)”
  • “However, the common case, you to the left-hand side tables.”

上面有很多無意義的句子,所以你必須深入挖掘才能找到有意義的句子,但不可否認馬爾可夫鏈可以產生一些非常有趣的輸出。

總結

馬爾可夫鏈有許多實際用途,在用於生成文字時它可能顯得比較有趣但不是很實用。除了展示了其娛樂性之外,該程式碼還說明了在沒有明確引用關係的情況下如何處理迴圈引用,如何靈活地使用 NSString 提供的列舉方法從文字中提取特徵,以及簡要說明了條件一致性(conditional conformances)的優點。

今天就講這些。期待下次一起分享更多的樂趣,在娛樂中進行學習。Friday Q&A 是由讀者的想法驅動的,所以如果你有一些想在這裡看到的話題,請給我傳送郵件

你喜歡這篇文章嗎?我正在賣收錄了這些文章的一本書!第二卷和第三卷現在也出來了!包括 ePub,PDF,實體版以及 iBook 和 Kindle。點選這裡檢視更多資訊

相關文章