原文连接: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
类,它将管理链中全部的 Words
。dom
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
时添加 Optional
对 Hashable
的实现:
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: " ") + "\"")
}
复制代码
为了演示,下面是这个程序的一些示例输出:
上面有不少无心义的句子,因此你必须深刻挖掘才能找到有意义的句子,但不能否认马尔可夫链能够产生一些很是有趣的输出。
马尔可夫链有许多实际用途,在用于生成文本时它可能显得比较有趣但不是很实用。除了展现了其娱乐性以外,该代码还说明了在没有明确引用关系的状况下如何处理循环引用,如何灵活地使用 NSString
提供的枚举方法从文本中提取特征,以及简要说明了条件一致性(conditional conformances)的优势。
今天就讲这些。期待下次一块儿分享更多的乐趣,在娱乐中进行学习。Friday Q&A
是由读者的想法驱动的,因此若是你有一些想在这里看到的话题,请给我发送邮件!
你喜欢这篇文章吗?我正在卖收录了这些文章的一本书!第二卷和第三卷如今也出来了!包括 ePub,PDF,实体版以及 iBook 和 Kindle。点击这里查看更多信息。