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

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

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

馬爾可夫鏈

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

馬爾可夫鏈有着普遍的應用,但最有趣的是用於文本生成。在本文生成領域,每一個狀態是文本的一部分,一般是一個單詞。狀態和轉換是由一些語料庫生成的,而後遍歷整個鏈併爲每一個狀態輸出單詞來生成文本。這樣生成的文本一般沒有實際意義,由於該鏈不包含足夠的信息來保留語料庫的任何潛在含義及語法結構,可是缺少意義自己卻給文本帶來了意料以外的樂趣。github

構建算法

鏈中的節點由 Word 類的實例表示,此類將會爲它所表示的單詞保存一個字符串,同時持有一組指向其餘單詞的連接。算法

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

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

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 數組可能會致使大量循環引用。爲了不內存泄漏,咱們須要手動清理那些內存。app

咱們引入 Chain 類,它將管理鏈中全部的 Wordsdom

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。點擊這裏查看更多信息

相關文章
相關標籤/搜索