爱上 Common Lisp, 或精明程序员的秘密武器
Table of Contents
- 1. 封面材料, 版权和许可证
- 2. 前言
- 3. 引言
- 4. Common Lisp 基础知识
- 5. 定义 Lisp 函数
- 6. 定义 Common Lisp 宏
- 7. 使用 Common Lisp 循环宏
- 8. 输入和输出
- 9. 绘制数据
- 10. 启发式引导搜索
- 11. 网络编程
- 12. 使用 Microsoft Bing 搜索 API
- 13. 访问关系数据库
- 14. 使用 MongoDB, Solr NoSQL 数据存储
- 15. 自然语言处理
- 16. 信息收集
- 17. 使用 CL 机器学习库
- 18. 反向传播神经网络
- 19. 使用带有 Web 服务接口的 Python 深度学习模型在 Common Lisp 中
- 20. 使用 PY4CL 库在 Common Lisp 中嵌入 Python
- 21. 自动生成知识图谱数据
- 22. 知识图谱导航器
- 23. 书籍总结
本书销售网址: http://leanpub.com/lovinglisp 此版本发布于 2020-08-03 这是一本 Leanpub 书籍. Leanpub 通过精益出版 (Lean Publishing) 流程为作者和出版商赋能. 精益出版是指使用轻量级工具和多次迭代来发布进行中的电子书, 以获取读者反馈, 进行调整, 直到你拥有合适的书籍并在此基础上建立吸引力.
本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可.
1. 封面材料, 版权和许可证
2. 前言
2.1. 2020 年 6 月发布的第六版说明
有两个示例可选地使用了 LispWorks Common Lisp4 附带的 CAPI 用户界面工具包, 并且可以在免费的个人版上运行. 第一个 CAPI 应用程序是 Knowledge Graph Navigator5, 第二个 CAPI 示例是 Knowledge Graph Creator6. 这两个示例都构建了用于处理知识图谱和语义网的实用工具.
我扩展了 Plot Library 章节, 使其可以生成 PNG 图形文件, 或者如果你使用的是 LispWorks 的免费个人版, 你也可以将绘图输出定向到交互式程序中的新窗口.
我增加了一个新章节, 关于使用 py4cl 库将 Python 库和应用程序代码嵌入到 Common Lisp 系统中. 我提供了新的示例, 用于在 Common Lisp 应用程序中嵌入 spaCy 和 TensorFlow 应用程序. 在早期版本中, 我使用了一个 Web 服务接口来包装使用 spaCy 和 TensorFlow 的 Python 代码. 我保留了那一章, 将其从 “Using Python Deep Learning Models In Common Lisp” 重命名为 “Using Python Deep Learning Models In Common Lisp With a Web Services Interface”. 此版本的新章节是 “Using the PY4CL Library to Embed Python in Common Lisp”.
2.2. 2019 年 9 月发布的第五版说明
添加了两个章节:
- 一个完整的应用程序, 用于处理文本以生成知识图谱数据 (目标是开源的 Neo4j 图数据库, 并且还支持 RDF 语义网/链接数据).
- 一个用于访问最先进的 spaCy 自然语言处理 (NLP) 库以及最先进的深度学习模型的库. 这些模型通过使用 spaCy, PyTorch 和 TensorFlow 等 Python 库的轻量级 Python 包装器实现. 这些示例取代了先前版本中的一个简单的混合 Java 和 Common Lisp 示例.
我在全书中适当增加了文本和解释, 并删除了 CouchDB 示例. 我对本书代码的打包方式做了较大改动. 我通过将示例作为多个 Quicklisp 库或应用程序提供, 重新组织了 GitHub 上的示例代码. 我现在对所有我的 Common Lisp 代码都这样做, 这使得编写可以组合成更大应用程序的较小库变得更容易. 在我自己的工作流程中, 我还喜欢使用 Makefile 目标来构建可以在其他计算机上运行而无需安装 Lisp 开发环境的独立应用程序. 请遵循前言末尾关于配置 Quicklisp 的说明, 以便轻松构建和使用本书的示例软件.
2.3. 为什么使用 Common Lisp?
为什么选择 Common Lisp? Common Lisp 不是一门旧语言吗? 现在还有很多人使用 Common Lisp 吗? 我相信使用像 Common Lisp, Clojure, Racket 和 Scheme 这样的 Lisp 语言都是敏捷软件开发中有用的秘密武器. 如果你在像 Java 企业版 (JEE) 这样的重量级平台上进行开发, 那么交互式开发过程和实时生产更新会让你感觉耳目一新. 是的, Common Lisp 是一门旧语言, 但随着时间的推移, 它带来了稳定性和极其优秀的编译器技术. 不同的 Common Lisp 系统在处理线程等方面也存在一些不一致性, 但只要稍加了解, 你就可以选择支持你需求的 Common Lisp 系统.
2.4. 作者的请求
亲爱的读者, 我花时间写这本书是为了帮助你. 我根据知识共享 “署名-相同方式共享, 禁止修改, 非商业性使用” 许可协议发布本书, 并将最低购买价格设为 5.00 美元, 以便让最多的读者能够接触到它. 根据此许可协议, 你可以与你的朋友和同事分享本书的 PDF 版本, 我鼓励你这样做. 如果你在网上找到这本书 (或者别人送给你), 并且你觉得它有价值, 那么请考虑做以下其中一项来支持我未来的写作工作, 并支持本书的未来更新:
- 在 leanpub.com/lovinglisp/7 购买本书的副本, 或在 https://leanpub.com/u/markwatson%5Bfn:8%5D 购买我的任何其他 leanpub 书籍.
- 聘请我作为顾问8.
我喜欢写作, 你的支持帮助我为我的书籍编写新版本和更新, 并开发新的书籍项目. 谢谢你!
2.5. 旧版书籍
本书的第四版于 2017 年 5 月发布, 主要变化如下:
- 添加了一个名为 KGCreator 的示例应用程序, 该程序处理文本数据以自动生成知识图谱数据. 此示例应用程序支持 Neo4j 图数据库以及语义网/链接数据系统. 主要变化如下:
- 添加了一个反向传播神经网络示例.
- 添加了一个使用基于 Java 的 Armed Bear Common Lisp 和流行的 DeepLearning4j 库的深度学习示例.
- 添加了一个启发式搜索示例.
- 使用 CLML 库添加了两个机器学习示例 (K-Means 聚类和 SVM 分类).
- 对先前文本进行了一些编辑.
第三版于 2014 年 10 月发布. 2014 版的主要变化如下:
- 我重写了 Common Lisp Basics 章节.
- 我在关于使用 QuickLisp 的章节中添加了内容.
第二版于 2013 年发布, 源自我在我的网站上发布的版本, 我将本书的制作移至 leanpub.com9.
2.6. 致谢
我要感谢 Jans Aasman10 作为本书第四版的技术编辑所做的贡献. Jans 是 Franz.com11 的 CEO, 该公司销售 Allegro Common Lisp12 以及用于语义网和链接数据应用的工具. 我要感谢以下人士为本书先前版本的改进提出了建议: Sam Steingold, Andrew Philpot, Kenny Tilton, Mathew Villeneuve, Eli Draluk, Erik Winkels, Adam Shimali, 和 Paolo Amoroso. 我还要感谢几位指出本书中印刷错误并提出具体建议的人: Martin Lightheart, Tong-Kiat Tan, Rainer Joswig, Gerold Rupprecht, 和 David Cortesi. 我要感谢以下 Reddit /r/lisp 读者指出了第五版中的错误: arnulfslayer, rpiirp, 和 itmuckel. 我要感谢 Ted Briscoe 指出了第 6 版中 spacy web client 示例的一个问题. 我要感谢 Paul Graham 创造了 “秘密武器” (The Secret Weapon) 这个短语 (在他出色的论文 “Beating the Averages” 中), 用来讨论 Lisp 的优势, 并允许我重用他的短语. 我尤其要感谢我的妻子 Carol Watson 为编辑本书所做的出色工作.
2.7. 设置你的 Common Lisp 开发系统和 Quicklisp
这些说明假定使用 SBCL. 有关 LispWorks, Franz Common Lisp 和 Clozure Common List 的评论, 请参见本节末尾. 我假设你已经按照 lisp-lang.org/learn/getting-started13 的说明安装了 SBCL 和 Quicklisp. 这些说明还会指导你安装用于 Emacs 的 Slime 扩展. 我同时使用 Emacs + Slime 和带有 Common Lisp 插件的 VSCode 来编辑 Common Lisp. 如果你喜欢 VSCode, 我推荐 Yasuhiro Matsumoto 的 Lisp 插件用于语法高亮. 对于 Emacs 和 VSCode, 我通常在终端窗口中运行一个单独的 REPL, 而不运行编辑器集成的 REPL. 我想我在使用在 shell 中运行的单独 REPL 方面是少数派. 我大约从 1982 年开始使用 Common Lisp, 而 Quicklisp 是我 Common Lisp 开发中最具革命性的变化 (甚至比获得硬件 Lisp Machine 和 Macintosh 上 Coral Common Lisp 的可用性更具革命性). 亲爱的读者, 我要请你相信我, 并采纳我从 Quicklisp 的创建者和维护者 Zach Beane14 那里采纳的以下建议:
- 如果文件
~/.config/common-lisp/source-registry.conf.d/projects.conf不存在于你的系统中, 请创建它. - 假设你已将本书的代码库 (loving-common-lisp) 克隆到你的主目录中 (如果你有克隆 git 仓库的特定位置, 请相应调整以下内容), 编辑此配置文件, 使其看起来像这样:
(:tree (:home "loving-common-lisp/src/") )
这将使得 loving-common-lisp/src/ 的子目录可以通过 Quicklisp 加载. 例如, 子目录 loving-common-lisp/src/spacy_client 包含一个名为 spacy 的包, 现在可以在你系统上的任何目录中使用以下命令访问它:
2.8. 本书中 Quicklisp 项目和小型示例列表
主要的示例库和应用程序将位于它们自己的包中. 本书中所有简短代码片段的函数和数据定义都在包 loving-snippets 中, 位于子目录 loving-common-lisp/src/loving_snippets. 每当你学习本书中的简短示例时, 我都假定你已经打开了一个 SBCL (或其他 Common Lisp) REPL 并加载了这个包:
$ sbcl * (ql:quickload "spacy_web_client")
在我的一个 Linux 笔记本电脑上, 出于我尚未发现的原因, 使用 ~/.config/common-lisp/source-registry.conf.d/projects.conf 来设置 Quicklisp 查找包的根目录不起作用. 如果碰巧这对你也不起作用, 你可以将示例书包的符号文件链接设置到你的 ~/.quicklisp/local-projects 目录. 这是 Quicklisp 存储你安装的库的本地副本的目录. 例如:
$ cd ~/.quicklisp/local-projects $ ln -s loving-common-lisp/src/loving_snippets . $ ln -s loving-common-lisp/src/kgcreator . $ ln -s loving-common-lisp/src/kbnlp . etc.
希望你不需要费心做这个变通方法.
虽然本书中大多数较长的示例都是 Quicklisp 项目, 但书中也有许多非常简短的代码片段, 位于子目录 src/code_snippets_for_book 中, 还有一些未配置为 Quicklisp 项目的简短程序示例在 src/loving_snippets 子目录中:
$ ls code_snippets_for_book closure1.lisp nested.lisp read-test-1.lisp readline-test.lisp do1.lisp read-from-string-test.lisp read-test-2.lisp recursion1.lisp Marks-MacBook:src $ ls loving_snippets HTMLstream.lisp astar_search.lisp macro1.lisp Hopfield_neural_network.lisp backprop_neural_network.lisp macro2.lisp README.md ambda1.lisp mongo_news.lisp
打包为 Quicklisp 项目的较长示例在 src 目录中是:
fasttag: 我的词性标注器solr_examples: 开源 Solr 搜索引擎的客户端categorize_summarize: 我的用于文本分类和生成摘要的 NLP 代码coref_web_client: 基于 spaCy 的 Web 服务的客户端, 用于执行指代消解 (即, 将文本中的代词替换为其所指的名词)hunchentoot_examples: Web 服务和 Web 客户端的示例spacy_web_client: 使用最先进的 NLP 深度学习模型的通用 Web 服务的客户端clml_examples: 使用 Common List 机器学习库的示例kbnlp: 我的 NLP 代码myutils: 本书中其他几个示例库中使用的杂项函数webscrape: 如何抓取网站的演示clsql_examples: 展示如何使用 CLSQL 库访问关系数据库的示例entities_dbpedia: 使用公共 DbPedia (来自 WikiPedia 的数据) 公共 Web 接口获取有关人物, 公司, 地点等信息.kgcreator: 我的用于处理文本, 提取实体以及为知识图谱生成数据的应用程序 (支持 Neo4J 和 RDF 语义网/链接数据应用程序)kgn: 应用程序 Knowledge Graph Navigator15plotlib: 一个非常简单的绘图库, 可将绘图写入 PNG 图形文件
我在本书中使用了 SBCL 实现的 Common Lisp. Franz, LispWorks, Clozure Common Lisp 等提供了许多优秀的 Common Lisp 实现. 如果你在将示例应用于你选择的 Common Lisp 实现时遇到很大困难, 并且进行 Web 搜索没有找到解决方案, 你可以通过我的网站 markwatson.com16 联系我. 可能不具可移植性的示例是为我的 KGCreator 示例创建独立可执行文件以及使用 Common Lisp Machine Learning 库的示例.
3. 引言
本书旨在让读者快速掌握 Common Lisp 编程. 虽然 Lisp 编程语言通常与人工智能相关联, 但本介绍是关于通用的 Common Lisp 编程技术. 稍后我们将探讨通用示例应用程序和人工智能示例. Common Lisp 程序示例发布在本书的 github 仓库中17.
3.1. 我为什么写这本书?
为什么书名叫 “爱上 Common Lisp”? 简单! 我使用 Lisp 将近 40 年了, 很少能找到编程语言和手头的编程工作之间更好的匹配. 然而, 我并不是 Lisp 的狂热粉丝. 我经常使用 Python 进行深度学习. 我喜欢 Ruby, Java 和 Javascript 用于服务器端编程, 在我为 SAIC 和 Disney 工作于任天堂视频游戏和虚拟现实系统的几年里, 我发现 C++ 是一个不错的选择, 因为有严格的运行时性能要求. 对于某些工作, 我发现逻辑编程范式很有用: 我也喜欢 Prolog 语言. 无论如何, 我热爱用 Lisp 编程, 特别是行业标准的 Common Lisp. 十多年前, 当我写本书的第二版时, 我几乎完全为一个医疗保健公司的人工智能项目和商业产品开发使用 Common Lisp. 在编写本书第三版时, 我并未在专业上使用 Common Lisp, 但自从 Quicklisp Common Lisp 包管理器发布以来, 我发现自己更喜欢使用 Common Lisp 来做一些小型副项目. 我在第三版的示例代码中全面使用了 Quicklisp, 以便你可以轻松安装所需的库. 对于本书的第四版和第五版, 我添加了更多使用神经网络和深度学习的示例. 在这个新的第六版中, 我添加了一个使用 CAPI 进行用户界面的完整应用程序. 作为程序员, 我们都 (希望) 享受运用我们的经验和智慧来解决有趣的问题. 我和妻子最近看了一个两晚 7 小时的 PBS 特别节目 “Joseph Campbell, and the Power of Myths”. Campbell 是一位将近 40 年的大学教授, 他说他总是建议他的学生 “追随自己的幸福” (follow their bliss), 不要满足于那些并非他们真正想做的工作和职业. 话虽如此, 我总是觉得当一份工作需要使用 Java, Python 或 Lisp 之外的其他语言时, 即使我可能会从中获得很多乐趣, 我也没有追随自己的幸福. 本书的目标是向你介绍我最喜欢的编程语言之一, Common Lisp. 我假设你已经知道如何用另一种语言编程, 但如果你是一个完全的初学者, 只要付出一些努力, 你仍然可以掌握本书中的材料. 我鼓励你付出这种努力.
3.2. Common Lisp 编程的免费软件工具
网络上有几种免费的 Common Lisp 编译器和运行时工具可用:
- CLISP – 遵循 GNU GPL 许可, 可用于 Windows, Macintosh 和 Linux/Unix
- Clozure Common Lisp (CCL) – 开源, 对 Mac OS X 和 Linux 支持良好
- CMU Common Lisp – 开源实现
- SBCL – 源自 CMU Common Lisp
- ECL – 使用单独的 C/C++ 编译器进行编译
- ABCL – 用于 JVM 的 Armed Bear Common Lisp
还有一些优秀的商业 Common Lisp 产品:
- LispWorks – 高质量且价格合理的系统, 适用于 Windows 和 Linux. 分发编译后的应用程序无需付费 lispworks.com.
- Allegro Common Lisp - 高质量, 强大的支持, 成本较高. franz.com
- MCL – Macintosh Common Lisp. 我在 1980 年代末期使用这个 Lisp 环境. MCL 非常好, 以至于我送掉了我的 Xerox 1108 Lisp Machine, 转而使用 Mac 和 MCL 进行开发工作. 现在是开源的, 但只能在旧版 MacOS 上运行.
我目前 (主要) 使用 SBCL, CCL 和 LispWorks. SBCL 编译器生成的代码速度非常快, 编译器警告在发现代码中的潜在问题方面非常有价值. 喜欢 CCL 是因为它编译速度快, 因此通常更适合开发. 在本书的学习过程中, 我将假设你正在使用 SBCL 或 CCL. 对于最后一章的示例, 你将需要 LispWorks, 免费的个人版对于试验示例应用程序和 CAPI 用户界面库来说已经足够了.
3.3. Lisp 与 Java 和 C++ 等语言有何不同?
这是一个陷阱问题! Lisp 与 Java 的相似性略高于 C++, 因为它们都有自动内存管理, 所以我们先比较 Lisp 和 Java. 在 Java 中, 变量是强类型的, 而在 Common Lisp 中, 值是强类型的. 例如, 考虑以下 Java 代码:
Float x = new Float(3.14f); String s = "the cat ran" ; Object any_object = null; any_object = s; x = s; // illegal: generates a // compilation error
这里, 在 Java 中, 变量是强类型的, 所以类型为 Float 的变量 x 不能合法地赋给一个字符串值: 第 5 行的代码会产生编译错误. Lisp 代码可以将一个值赋给一个变量, 然后再赋给一个不同类型的另一个值.
Java 和 Lisp 都提供自动内存管理. 在这两种语言中, 你都可以创建新的数据结构, 而不必担心在数据不再使用时 (或者更准确地说, 不再被引用时) 释放内存.
Common Lisp 是一种 ANSI 标准语言. 不同 Common Lisp 实现之间以及不同平台上的可移植性非常好. 我使用过 Clozure Common Lisp, SBCL, Allegro Lisp (来自 Franz Inc), LispWorks 和 CLISP, 它们在 Windows, Mac OS X 和 Linux 上都运行良好. 作为 Common Lisp 开发者, 你将在工具和平台方面拥有极大的灵活性.
ANSI Common Lisp 是第一个成为 ANSI 标准语言的面向对象语言. Common Lisp 对象系统 (CLOS) 可能是面向对象编程的最佳平台.
在 C++ 程序中, 一个常见的影响程序效率的 bug 是忘记释放不再使用的内存. 在虚拟内存系统中, 程序内存使用量增加的影响通常只是系统性能下降, 但如果所有可用的虚拟内存耗尽, 则可能导致系统崩溃或故障. 更糟糕的 C++ 错误类型是释放内存后又尝试使用它. 你能说 “程序崩溃” 吗? C 程序也存在同样类型的内存相关错误.
由于计算机处理能力通常远比软件开发成本便宜, 因此放弃几个百分点的运行时效率, 让编程环境的运行时库为你管理内存几乎总是值得的. 像 Lisp, Ruby, Python 和 Java 这样的语言据说会执行自动垃圾回收.
我写过六本关于 Java 的书, 我曾被引述说, 对我而言, 用 Java 编程的效率大约是用 C++ 编程的两倍 (就我的时间而言). 我基于在 SAIC, PacBell, Angel Studios, Nintendo 和 Disney 的项目上大约十年的 C++ 经验得出这个结论. 我发现 Common Lisp 和其他 Lisp 语言 (如 Clojure 和 Scheme) 的效率大约是 Java 的两倍 (同样, 就我的时间而言). 这是正确的: 我声称在使用 Common Lisp 与 C++ 相比, 我的编程生产力提高了四倍.
我所说的编程生产力是什么意思? 很简单: 对于给定的工作, 我需要多长时间来设计, 编码, 调试以及以后维护给定任务的软件.
3.4. 在 Lisp 环境中工作的优势
我们很快就会看到 Lisp 不仅仅是一种语言; 它也是一个编程环境和运行时环境. 本书的开头介绍了 Lisp 编程的基础知识. 在后面的章节中, 我们将开发有趣且非平凡的 Common Lisp 程序, 我认为这些程序在其他语言和编程环境中会更难实现. 在 Lisp 环境中编程的一大优势是你可以建立一个环境, 并以交互方式编写新代码并小块地测试新代码. 我们将在关于自然语言处理的章节中介绍使用大量数据的编程, 但让我分享一个我在 Lisp 中效率高得多的通用用例: 我过去的大部分 Lisp 编程工作是为我的公司 www.knowledgebooks.com 编写商业自然语言处理 (NLP) 程序. 我的 Lisp NLP 代码使用了大量的内存驻留数据; 例如: 用于不同类型单词的哈希表, 用于文本分类的哈希表, 200,000 个地名专有名词 (城市, 县, 河流等), 以及大约 40,000 个不同国籍的常见名字和姓氏. 如果我用 C++ 编写我的 NLP 产品, 我可能会使用关系数据库来存储这些数据, 因为如果我每次运行 C++ 程序的测试时都将所有这些数据读入内存, 那么每次运行程序测试我都要等待 30 秒. 当我开始在任何 Common Lisp 环境中工作时, 我确实需要一次性将语言数据加载到内存中, 但是之后我可以连续几个小时进行编码/测试/编码/测试…, 而无需重新加载我的程序需要运行的数据的启动开销. 由于 Lisp 开发的交互性, 我可以在追踪错误和编写新代码时测试小段代码. 这是个人偏好, 但我发现稳定的 Common Lisp 语言和迭代式 Lisp 编程环境的结合比其他语言和编程环境的生产力要高得多.
4. Common Lisp 基础知识
本章中的材料将作为 Common Lisp 的介绍. 我试图使本书成为学习 Common Lisp 的独立资源, 并提供代码示例来执行常见任务. 如果你已经了解 Common Lisp 并购买本书是为了获取本书后面的代码示例, 那么你或许可以跳过本章. 在本章的学习过程中, 我们将使用 SBCL 和其他 Common Lisp 系统内置的交互式 shell, 或称为 repl. 对于本章来说, 你只需下载并安装 SBCL18 即可. 如果你还没有安装, 请立即安装 SBCL.
4.1. SBCL 入门
当我们启动 SBCL 时, 我们会看到一条介绍性消息, 然后是一个输入提示符. 我们将从一个简短的教程开始, 引导你完成使用 SBCL repl (其他 Common LISP 系统非常相似) 的会话. repl 是一个交互式控制台, 你可以在其中键入表达式并查看评估这些表达式的结果. 表达式可以是一个粘贴到 repl 中的大块代码, 使用 load 函数将 Lisp 代码加载到 repl 中, 调用函数来测试它们等等. 假设你的系统上已安装 SBCL, 通过运行 SBCL 程序来启动 SBCL:
% sbcl (running SBCL from: /Users/markw/sbcl) This is SBCL 2.0.2, an implementation of ANSI Common Lisp. More information about SBCL is available at <http://www.sbcl.org/>. SBCL is free software, provided as is, with absolutely no warranty. It is mostly in the public domain; some portions are provided under BSD-style licenses. See the CREDITS and COPYING files in the distribution for more information. * (defvar x 1.0) X * x 1.0 * (+ x 1) 2.0 * x 1.0 * (setq x (+ x 1)) 2.0 * x 2.0 * (setq x "the dog chased the cat") "the dog chased the cat" * x "the dog chased the cat" * (quit) Bye.
我们在第 11 行通过定义一个新变量 x 开始. 注意 defvar 宏的值是定义的符号. Lisp 读取器打印 X 为大写, 因为符号通常转换为大写 (我们稍后会看例外).
在 Lisp 中, 变量可以引用任何数据类型. 我们首先将浮点值赋给变量 x, 在第 17 行使用 + 函数将 1 加到 x, 在第 23 行和 29 行使用 setq 函数更改 x 的值, 先改为另一个浮点值, 最后将 x 设置为字符串值. 你会注意到一件事: 函数名总是先出现, 然后是函数的参数. 此外, 括号用于分隔表达式.
我 1976 年学习 Lisp 编程时, 我的教授半开玩笑地告诉我们 Lisp 是 “Lots-of Irritating Superfluous Parenthesis” (许多恼人的多余括号) 的缩写. 当你刚开始学习 Lisp 编程时, 这可能有点道理, 但你会很快习惯括号, 特别是如果你使用像 Emacs 这样的编辑器, 它可以自动为你缩进 Lisp 代码, 并在你键入每个右括号时高亮显示对应的左括号. 许多其他编辑器也支持 Lisp 编码, 但我个人使用 Emacs 或有时使用 VScode (带有 Common Lisp 插件) 来编辑 Lisp 代码.
在继续下一章之前, 请花点时间在你的计算机上安装 SBCL, 并尝试在 Lisp 监听器中输入一些表达式. 如果遇到错误或想退出, 请尝试使用 quit 函数:
* (+ 1 2 3 4) 10 * (quit) Bye.
如果你遇到错误, 可以输入 help 来获取处理错误的选项. 当我遇到错误并且很清楚是什么原因导致错误时, 我通常只输入 :a 来中止错误).
正如我们在引言中讨论的, 有许多不同的 Lisp 编程环境可供你选择. 我推荐一套免费工具: Emacs, Quicklisp, slime 和 SBCL. Emacs 是一款优秀的文本编辑器, 可扩展性强, 能很好地配合多种编程语言和文档类型 (例如 HTML 和 XML). Slime 是一个 Emacs 扩展包, 极大地便利了 Lisp 开发. SBCL 是一个健壮的 Common Lisp 编译器和运行时系统, 常用于生产环境.
我们将在后面的章节中介绍 Quicklisp 包管理器以及如何使用 Quicklisp 来设置 Slime 和 Emacs.
在本书中, 我不会花太多时间介绍 Emacs 作为文本编辑器的使用, 因为你可以通过复制本书文本中的大多数示例代码片段并将其粘贴到 SBCL repl 中, 或者直接将本书示例源文件加载到 repl 中来尝试它们. 如果你已经使用 Emacs, 我建议你尽早设置 Slime 并开始用它进行开发. 如果你还不是 Emacs 用户, 并且不介意花精力学习 Emacs, 那么首先在网上搜索 Emacs 教程. 话虽如此, 你可以轻松地使用你喜欢的任何文本编辑器和 SBCL repl 来使用本书中的示例代码. 我不使用 vi 或 vim 编辑器, 但如果 vi 是你编辑文本的首选武器, 那么在网上搜索 “common lisp vi vim repl” 应该能让你开始使用 vi 或 vim 进行 Common Lisp 代码开发. 如果你还不是 Emacs 或 vi 用户, 那么推荐使用 VSCode 搭配 Common Lisp 插件.
在这里, 我们将假设在 Windows, Unix, Linux 或 Mac OS X 下, 你将使用一个命令窗口运行 SBCL, 并使用一个可以编辑纯文本文件的单独编辑器.
4.2. 让 repl 更好用: 使用 rlwrap
在阅读上一节时, 你 (希望!) 已经玩过了 SBCL 交互式 repl. 如果你还没玩过 repl, 我不会太挑剔, 只是想说, 如果你不边读边玩示例, 你就无法从本书中获得全部好处.
你是否注意到在 SBCL repl 中退格键不起作用? 解决这个问题的方法是安装 GNU rlwrap 实用程序. 在 OS X 上, 假设你已经安装了 homebrew19, 使用以下命令安装 rlwrap:
brew install rlwrap
如果你正在运行 Ubuntu Linux, 使用以下命令安装 rlwrap:
sudo apt-get install rlwrap
然后你可以为 bash 或 zsh 创建一个别名, 使用类似以下的方式来定义一个命令 rsbcl:
alias rsbcl='rlwrap sbcl'
这很好, 只需要记住, 如果你不需要 rlwrap 命令行编辑功能就运行 sbcl, 如果你需要命令行编辑功能就运行 rsbcl. 话虽如此, 我发现我总是想用命令行编辑功能运行 SBCL, 所以我在我的电脑上重新定义了 sbcl, 使用:
-> ~ which sbcl /Users/markw/sbcl/sbcl -> ~ alias sbcl='rlwrap /Users/markw/sbcl/sbcl'
这个别名在我的笔记本电脑和服务器上是不同的, 因为我通常不会将 SBCL 安装在默认安装目录中. 对于我的每台电脑, 我都会在我的 .zshrc 文件 (如果我运行 zsh) 或我的 .bashrc 文件 (如果我运行 bash) 中添加一个适当的别名.
4.3. Lisp 编程基础
虽然我们在本书中将使用 SBCL, 但任何 Common Lisp 环境都可以. 在前面的部分中, 我们看到了顶层 Lisp 提示符, 以及如何键入任何将被求值的表达式:
* 1 1 * 3.14159 3.14159 * "the dog bit the cat" "the dog bit the cat" * (defun my-add-one (x) (+ x 1)) MY-ADD-ONE * (my-add-one -10) -9
注意, 当我们在第 7 行和第 8 行定义函数 my-add-one 时, 我们将定义分成了两行, 在第 8 行你看不到 SBCL 的 “*” 提示符 – 这让你知道你还没有输入一个完整的表达式. 顶层 Lisp 求值器会计算括号, 并在右括号的数量等于左括号的数量且表达式在括号匹配时完成时, 认为一个形式是完整的. 我倾向于在脑海中计数, 每遇到一个左括号加一, 每遇到一个右括号减一 – 当我回到零时, 表达式就完成了. 当我们求值一个数字 (或一个变量) 时, 没有括号, 所以当我们敲击新行 (或回车) 时, 求值就会进行.
Lisp 读取器默认会尝试求值你输入的任何形式. 有一个读取器宏 ‘ 可以阻止表达式的求值. 你可以使用 ‘ 字符或 quote:
* (+ 1 2) 3 * '(+ 1 2) (+ 1 2) * (quote (+ 1 2)) (+ 1 2) *
Lisp 支持全局变量和局部变量. 可以使用 defvar 声明全局变量:
* (defvar *x* "cat") *X*n * *x* "cat" * (setq *x* "dog") "dog" * *x* "dog" * (setq *x* 3.14159) 3.14159 * *x* 3.14159
使用 defvar 定义全局变量时需要注意一点: 声明的全局变量是动态作用域的. 我们稍后会讨论动态作用域与词法作用域, 但现在提醒一下: 如果你定义了一个全局变量, 请避免在函数内部重新定义同名变量. Lisp 程序员通常使用一种全局变量命名约定, 即以 * 字符开头和结尾的动态作用域全局变量. 如果你遵循这个命名约定, 并且也不在局部变量名中使用 * 字符, 你就不会遇到麻烦. 为了方便起见, 在本书的简短示例中, 我并不总是遵循这个约定.
Lisp 变量没有类型. 相反, 赋给变量的值具有类型. 在最后一个例子中, 变量 x 被设置为字符串, 然后是浮点数. Lisp 类型支持继承, 可以被认为是一个以类型 t 为顶点的层次树. (实际上, 类型层次结构是一个 DAG, 但我们现在可以忽略它.) Common Lisp 还有一个强大的面向对象编程设施, 即 Common Lisp 对象系统 (CLOS), 我们将在后面的章节中讨论.
以下是部分类型列表 (注意缩进表示是前一个类型的子类型):
t [top level type (all other types are a sub-type)] sequence list array vector string number float rational integer ratio complex character symbol structure function hash-table
我们可以使用 typep 函数来测试任何变量或表达式的值的类型, 或者使用 type-of 来获取任何值的类型信息):
* (setq x '(1 2 3)) (1 2 3) * (typep x 'list) T * (typep x 'sequence) T * (typep x 'number) NIL * (typep (+ 1 2 3) 'number) T * (type-of 3.14159) single-float * (type-of "the dog ran quickly") (simple-array character (19)) * (type-of 100193) (integer 0 4611686018427387903)
所有 ANSI 标准 Common Lisp 实现的顶层监听器都有一个有用的特性, 即它将 * 设置为最后求值的表达式的值. 例如:
* (+ 1 2 3 4 5) 15 * * 15 * (setq x *) 15 * x 15
所有 Common Lisp 环境都将 * 设置为最后求值的表达式的值. 这个例子可能有点令人困惑, 因为 * 也是 SBCL repl 中指示你可以输入新表达式进行求值的提示符字符. 例如, 在第 3 行, 第一个 * 字符是 repl 提示符, 第二个 * 是我们输入的, 用于查看我们输入的前一个表达式的值.
通常, 当你交互式地测试新代码时, 你会调用一个你刚刚编写的带有测试参数的函数; 保存中间结果以便以后测试很有用. 正是这种创建复杂数据结构然后用使用或更改这些数据结构的代码进行实验的能力, 使得 Lisp 编程环境如此高效.
Common Lisp 是一种词法作用域语言, 这意味着变量声明和函数定义可以嵌套, 并且可以在嵌套的 let 形式中使用相同的变量名; 当使用一个变量时, 会在当前的 let 形式中搜索该变量的定义, 如果找不到, 则搜索下一个外部的 let 形式. 当然, 这种对变量正确声明的搜索是在编译时完成的, 因此不需要额外的运行时开销. 我们可以在彼此内部以及 let 表达式内部嵌套 defun 特殊形式, 但这会全局定义嵌套函数. 我们使用特殊形式 flet 和 labels 在作用域环境中定义函数. 在 labels 特殊形式内部定义的函数可以是递归的, 而在 flet 特殊形式内部定义的函数不能是递归的. 考虑文件 nested.lisp 中的以下示例 (所有示例文件都在 src 目录中):
(flet ((add-one (x) (+ x 1)) (add-two (x) (+ x 2))) (format t "redefined variables: ~A ~A~%" (add-one 100) (add-two 100))) (let ((a 3.14)) (defun test2 (x) (print x)) (test2 a)) (test2 50) (let ((x 1) (y 2)) ;; define a test function nested inside a let statement: (flet ((test (a b) (let ((z (+ a b))) ;; define a helper function nested inside a let/function/let: (flet ((nested-function (a) (+ a a))) (nested-function z))))) ;; call nested function 'test': (format t "test result is ~A~%" (test x y)))) (let ((z 10)) (labels ((test-recursion (a) (format t "test-recursion ~A~%" (+ a z)) (if (> a 0) (test-recursion (- a 1))))) (test-recursion 5)))
我们在第 1-5 行定义了一个顶层 flet 特殊形式, 它定义了两个嵌套函数 add-one 和 add-two, 然后在 flet 特殊形式的主体中调用每个嵌套函数. 多年来, 我在 let 表达式中使用嵌套的 defun 特殊形式来定义局部函数, 你会在后面的一些示例中看到这种用法. 然而, 在 defun 特殊形式内部定义的函数具有全局可见性, 因此它们并没有隐藏在它们被定义的局部上下文中. 第 7-12 行中嵌套的 defun 示例表明函数 test2 在当前包内具有全局可见性.
在 flet 特殊形式内部定义的函数可以访问包含 flet (也适用于 labels) 的外部作用域中定义的变量. 我们在第 14-24 行看到这一点, 其中在 let 表达式中定义的局部变量 x 和 y 在 flet 内部定义的函数 nested-function 中可见.
第 26-31 行的最后一个示例显示了一个在 labels 特殊形式内部定义的递归函数.
假设我们在 src 目录中启动了 SBCL, 那么我们可以使用 Lisp 的 load 函数来求值子目录 code_snippets_for_book 中文件 nested.lisp 的内容, 使用 load 函数:
* (load "./code_snippets_for_book/nested.lisp") redefined variables: 101 102 3.14 50 test result is 6 test-recursion 15 test-recursion 14 test-recursion 13 test-recursion 12 test-recursion 11 test-recursion 10 T *
函数 load 在成功加载文件后返回 t (打印为大写 T).
我们将在后面的章节中频繁使用 Common Lisp 向量和数组, 但这里也会简要介绍它们. 单维数组也称为向量. 尽管通常有更高效的函数来处理向量, 但我们只看处理任何类型数组 (包括向量) 的通用函数. Common Lisp 支持同名但接受不同参数类型的函数; 我们将在后面关于 CLOS 的章节中详细讨论这一点. 我们将首先定义三个向量 v1, v2 和 v3:
* (setq v1 (make-array '(3))) #(NIL NIL NIL) * (setq v2 (make-array '(4) :initial-element "lisp is good")) #("lisp is good" "lisp is good" "lisp is good" "lisp is good") * (setq v3 #(1 2 3 4 "cat" '(99 100))) #(1 2 3 4 "cat" '(99 100))
在第 1 行, 我们定义了一个一维数组 (或向量), 包含三个元素. 在第 3 行, 我们指定了赋给数组 v2 每个元素的默认值. 在第 5 行, 我使用了指定数组字面值的形式, 使用特殊字符 #. 函数 aref 可用于访问数组中的任何元素:
* (aref v3 3) 4 * (aref v3 5) '(99 100) *
注意数组的索引是基于零的; 也就是说, 索引从零开始表示序列的第一个元素. 还要注意数组元素可以是任何 Lisp 数据类型. 到目前为止, 我们已经使用了特殊操作符 setq 来设置变量的值. Common Lisp 有一个 setq 的通用版本叫做 setf, 它可以设置列表, 数组, 哈希表等中的任何值. 你可以在所有情况下使用 setf 代替 setq, 但反之则不行. 这里有一个简单的例子:
* v1 #(NIL NIL NIL) * (setf (aref v1 1) "this is a test") "this is a test" * v1 #(NIL "this is a test" NIL) *
在编写新代码或进行快速编程实验时, 通常最容易 (即最快编程) 使用列表来构建有趣的数据结构. 然而, 随着程序的成熟, 通常会修改它们以使用更高效 (在运行时) 的数据结构, 如数组和哈希表.
4.4. 符号
我们将在 Common Lisp Packages 章节中更详细地讨论符号. 现在, 你只需要理解符号可以是引用变量的名称. 例如:
> (defvar *cat* "bowser") *CAT* * *cat* "bowser" * (defvar *l* (list *cat*)) *L* * *l* ("bowser") *
请注意, 第一个 defvar 返回定义的符号作为其值. 符号几乎总是转换为大写. 这个 “大写规则” 的一个例外是当我们使用竖线字符定义可能包含空格的符号时:
* (defvar |a symbol with Space Characters| 3.14159) |a symbol with Space Characters| * |a symbol with Space Characters| 3.14159 *
4.5. 列表操作
列表是 Common Lisp 的基本数据结构. 在本节中, 我们将介绍一些更常用的列表操作函数. 本节描述的所有函数都有一个共同点: 它们不修改它们的参数.
在 Lisp 中, 一个 cons 单元是一个包含两个指针的数据结构. 通常, cons 单元中的第一个指针指向列表的第一个元素, 第二个指针指向表示原始列表其余部分开头的另一个 cons 单元.
函数 cons 接受两个参数, 并将它们存储在一个新的 cons 数据结构的两个指针中. 例如:
* (cons 1 2) (1 . 2) * (cons 1 '(2 3 4)) (1 2 3 4) *
第一个形式求值为一个 cons 数据结构, 而第二个形式求值为一个也是正常列表的 cons 数据结构. 不同之处在于, 在第二种情况下, 新创建的 cons 数据结构的第二个指针指向另一个 cons 单元.
首先, 我们将声明两个全局变量 l1 和 l2, 我们将在示例中使用它们. 列表 l1 包含五个元素, 列表 l2 包含四个元素:
* (defvar l1 '(1 2 (3) 4 (5 6))) L1 * (length l1) 5 * (defvar l2 '(the "dog" calculated 3.14159)) L2 * l1 (1 2 (3) 4 (5 6)) * l2 (THE "dog" CALCULATED 3.14159) >
你也可以使用函数 list 来创建一个新列表; 传递给函数 list 的参数是创建列表的元素:
* (list 1 2 3 'cat "dog") (1 2 3 CAT "dog") *
函数 car 返回列表的第一个元素, 函数 cdr 返回一个移除了第一个元素的列表 (但不修改其参数):
* (car l1) 1 * (cdr l1) (2 (3) 4 (5 6)) *
使用 car 和 cdr 调用的组合可以提取列表中的任何元素:
* (car (cdr l1)) 2 * (cadr l1) 2 *
注意我们可以将 car 和 cdr 的调用组合成一个单一的函数调用, 在本例中是函数 cadr. Common Lisp 定义了所有形式为 cXXr, cXXXr 和 cXXXXr 的函数, 其中 X 可以是 a 或 d.
假设我们想从嵌套列表 l1 中提取值 5. 通过使用 car 和 cdr 的组合进行一些实验可以完成任务:
* l1 (1 2 (3) 4 (5 6)) * (cadr l1) 2 * (caddr l1) (3) (car (caddr l1)) 3 * (caar (last l1)) 5 * (caar (cddddr l1)) 5 *
函数 last 返回列表的最后一个 cdr (即, 列表中的最后一个元素, 包含在一个列表中):
* (last l1) ((5 6)) *
Common list 提供了 car 和 cdr 的替代函数, 你可能会发现它们更具可读性: first, second, third, fourth 和 rest. 以下是一些示例:
* (defvar *x* '(1 2 3 4 5)) *X* * (first *x*) 1 * (rest *x*) (2 3 4 5) * (second *x*) 2 * (third *x*) 3 * (fourth *x*) 4
函数 nth 接受两个参数: 一个顶层列表元素的索引和一个列表. 第一个索引参数是基于零的:
* l1 (1 2 (3) 4 (5 6)) * (nth 0 l1) 1 * (nth 1 l1) 2 * (nth 2 l1) (3) *
函数 cons 将一个元素添加到列表的开头, 并返回新列表作为其值 (它不修改其参数). 添加到列表开头的元素可以是任何 Lisp 数据类型, 包括另一个列表:
* (cons 'first l1) (FIRST 1 2 (3) 4 (5 6)) * (cons '(1 2 3) '(11 22 33)) ((1 2 3) 11 22 33) *
函数 append 接受两个列表作为参数, 并返回这两个列表连接在一起的值:
* l1 (1 2 (3) 4 (5 6)) * l2 ('THE "dog" 'CALCULATED 3.14159) * (append l1 l2) (1 2 (3) 4 (5 6) THE "dog" CALCULATED 3.14159) * (append '(first) l1) (FIRST 1 2 (3) 4 (5 6)) *
初学 Lisp 程序员常犯的一个错误是不理解列表中的共享结构. 考虑以下示例, 我们通过重用列表 x 的三个副本来生成列表 y:
* (setq x '(0 0 0 0)) (0 0 0 0) * (setq y (list x x x)) ((0 0 0 0) (0 0 0 0) (0 0 0 0)) * (setf (nth 2 (nth 1 y)) 'x) X * x (0 0 X 0) * y ((0 0 X 0) (0 0 X 0) (0 0 X 0)) * (setq z '((0 0 0 0) (0 0 0 0) (0 0 0 0))) ((0 0 0 0) (0 0 0 0) (0 0 0 0)) * (setf (nth 2 (nth 1 z)) 'x) X * z ((0 0 0 0) (0 0 X 0) (0 0 0 0)) *
当我们更改变量 x 引用的共享结构时, 该更改会反映在列表 y 中三次. 当我们创建存储在变量 z 中的列表时, 我们没有使用共享结构.
4.6. 使用数组和向量
使用列表很容易, 但访问列表元素所花费的时间与列表的长度成正比. 数组和向量在运行时比长列表更有效, 因为列表元素保存在必须搜索的链表上. 访问短列表的任何元素都很快, 但对于包含数千个元素的序列, 使用向量和数组更快.
默认情况下, 数组和向量的元素可以是任何 Lisp 数据类型. 创建数组时有选项可以告诉 Common Lisp 编译器给定的数组或向量将只包含单一数据类型 (例如, 浮点数), 但我们在本书中不会使用这些选项.
向量是数组的一种特殊化; 向量是只有一维的数组. 为了效率, 有些函数只对向量进行操作, 但由于数组函数也适用于向量, 我们将专注于数组. 在下一节中, 我们将介绍作为向量特殊化的字符字符串.
我们可以使用通用的 make-sequence 函数来制作一个单维数组 (即向量). 重启 sbcl 并尝试:
* (defvar x (make-sequence 'vector 5 :initial-element 0)) X * x #(0 0 0 0 0) *
在这个例子中, 请注意向量的打印格式, 它看起来像一个列表, 前面有一个 # 字符. 如上一节所示, 我们使用函数 make-array 来创建数组:
* (defvar y (make-array '(2 3) :initial-element 1)) Y * y #2A((1 1 1) (1 1 1)) >
注意数组的打印格式: 它看起来像一个列表, 前面有一个 # 字符和维数整数.
除了使用 make-sequence 创建向量之外, 我们可以将整数作为 make-array 的第一个参数传递, 而不是维度值列表. 我们还可以通过使用函数 vector 并提供向量内容作为参数来创建向量:
* (make-array 10) #(NIL NIL NIL NIL NIL NIL NIL NIL NIL NIL) * (vector 1 2 3 'cat) #(1 2 3 CAT) *
函数 aref 用于访问序列元素. 第一个参数是一个数组, 其余参数是数组索引. 例如:
* x #(0 0 0 0 0) * (aref x 2) 0 * (setf (aref x 2) "parrot") "parrot" * x #(0 0 "parrot" 0 0) * (aref x 2) "parrot" * y #2A((1 1 1) (1 1 1)) * (setf (aref y 1 2) 3.14159) 3.14159 * y #2A((1 1 1) (1 1 3.14159)) *
4.7. 使用字符串
很可能你最初的 Lisp 程序就会涉及到字符字符串的使用. 在本节中, 我们将涵盖基础知识: 创建字符串, 连接字符串以创建新字符串, 字符串中的子字符串, 以及从较长字符串中提取子字符串. 我们将在这里介绍的字符串函数不修改它们的参数; 相反, 它们返回新的字符串作为值. 为了效率, Common Lisp 确实包含了修改其参数的破坏性字符串函数, 但我们在这里不会讨论这些破坏性函数.
我们之前看到字符串是一种向量类型, 而向量又是一种数组类型 (而数组又是一种序列类型). 对 Common Lisp 类型系统的全面介绍超出了本教程介绍 Common Lisp 的范围; Guy Steele 的 “Common Lisp, The Language” 对 Common Lisp 类型有很好的处理, 该书有印刷版并且可以在网上免费获得. 许多用于处理字符串的内置函数实际上更通用, 因为它们是为类型序列定义的. Common Lisp Hyperspec 是另一个你可以在网上找到的很棒的免费资源. 我建议你下载 Guy Steele 的优秀参考书的 HTML 版本和 Common Lisp Hyperspec, 并将两者都保存在你的计算机上. 如果你继续使用 Common Lisp, 最终你会想要阅读 Steele 的整本书并将 Hyperspec 用作参考.
以下文本是从 Common Lisp repl 的输入和输出中捕获的. 首先, 我们将声明两个全局变量 s1 和 space, 它们包含字符串值:
* (defvar s1 "the cat ran up the tree") S1 * (defvar space " ") SPACE *
字符串最常见的操作之一是将两个或多个字符串连接成一个新字符串:
* (concatenate 'string s1 space "up the tree") "the cat ran up the tree up the tree" *
注意函数 concatenate 的第一个参数是函数应返回的序列类型; 在本例中, 我们想要一个字符串. 另一个常见的字符串操作是搜索子字符串:
* (search "ran" s1) 8 * (search "zzzz" s1) NIL *
如果未找到搜索字符串 (函数 search 的第一个参数), 函数 search 返回 nil, 否则 search 返回第二个参数字符串中的索引. 函数 search 接受几个可选的关键字参数 (有关关键字参数的讨论, 请参见下一章):
(search search-string a-longer-string :from-end :test :test-not :key :start1 :start2 :end1 :end2)
在我们的讨论中, 我们将只使用关键字参数 :start2 来指定第二个参数字符串中的起始搜索索引, 以及 :from-end 标志来指定搜索应从第二个参数字符串的末尾开始并向字符串的开头反向进行:
* (search " " s1) 3 * (search " " s1 :start2 5) 7 * (search " " s1 :from-end t) 18 *
序列函数 subseq 可用于字符串以从较长字符串中提取子字符串:
* (subseq s1 8) "ran up the tree" >
在这里, 第二个参数指定起始索引; 从起始索引到字符串末尾的子字符串被返回. 一个可选的第三个索引参数指定了你想要提取的最后一个字符索引加一:
* (subseq s1 8 11) "ran" *
从字符串的开头或结尾移除空白 (或其他) 字符通常很有用:
* (string-trim '(#\space #\z #\a) " a boy said pez") "boy said pe" *
字符 #\\space 是空格字符. 其他常见的被修剪的字符是 #\\tab 和 #\\newline. 还有用于将字符串转换为大写或小写的实用函数:
* (string-upcase "The dog bit the cat.") "THE DOG BIT THE CAT." * (string-downcase "The boy said WOW!") "the boy said wow!" >
我们尚未讨论变量的相等性. 函数 eq 在两个变量引用内存中相同数据时返回 true. 函数 eql 在参数引用内存中相同数据, 或者它们是相等的数字或字符时返回 true. 函数 equal 更宽松: 它在两个变量求值后打印相同时返回 true. 更正式地说, 函数 equal 在 car 和 cdr 递归地彼此相等时返回 true. 一个例子将使这更清晰:
* (defvar x '(1 2 3)) X * (defvar y '(1 2 3)) Y * (eql x y) NIL * (equal x y) T * x (1 2 3) * y (1 2 3) *
对于字符串, 函数 string= 比使用函数 equal 略微高效:
* (eql "cat" "cat") NIL * (equal "cat" "cat") T * (string= "cat" "cat") T *
Common Lisp 字符串是字符序列. 函数 char 用于从字符串中提取单个字符:
* s1 "the cat ran up the tree" * (char s1 0) #\t * (char s1 1) #\h *
4.8. 使用哈希表
哈希表是非常有用的数据类型. 虽然使用列表和 assoc 函数确实可以达到同样的效果, 但如果列表包含许多元素, 哈希表比列表效率高得多. 例如:
* (defvar x '((1 2) ("animal" "dog"))) X * (assoc 1 x) (1 2) * (assoc "animal" x) NIL * (assoc "animal" x :test #'equal) ("animal" "dog") *
函数 assoc 的第二个参数是一个由 cons 单元组成的列表. 函数 assoc 搜索一个子列表 (在第二个参数中), 其 car (即第一个元素) 等于函数 assoc 的第一个参数. 这个例子中可能令人惊讶的是 assoc 似乎适用于整数作为第一个参数, 但不适用于字符串. 原因在于默认情况下相等性测试是用 eql 完成的, eql 测试两个变量是否引用相同的内存位置, 或者如果它们是数字或字符是否相同. 在最后一次调用 assoc 时, 我们使用了 “:test #’equal” 来让 assoc 使用函数 equal 来测试相等性.
使用列表和 assoc 的问题在于它们对于大型列表效率非常低. 我们将看到使用哈希表编码并不更困难.
哈希表存储键和值对之间的关联, 很像我们上一个使用 assoc 函数的例子. 默认情况下, 哈希表使用 eql 来测试查找键匹配时的相等性. 我们将使用哈希表复制前面的例子:
* (defvar h (make-hash-table)) H * (setf (gethash 1 h) 2) 2 * (setf (gethash "animal" h) "dog") "dog" * (gethash 1 h) 2 ; T * (gethash "animal" h) NIL ; NIL *
注意 gethash 返回多个值: 第一个值是匹配作为第一个参数传递给函数 gethash 的键的值, 第二个返回的值如果找到键则为 true, 否则为 nil. 如果哈希值为 nil, 则第二个返回的值可能很有用.
由于我们还没有看到如何处理函数的多个返回值, 我们将在此处进行介绍 (处理多个返回值的方法有很多, 我们只介绍其中一种):
* (multiple-value-setq (a b) (gethash 1 h)) 2 * a 2 * b T *
假设变量 a 和 b 已经声明, 变量 a 将被设置为从 gethash 返回的第一个值, 变量 b 将被设置为第二个返回的值.
如果我们使用符号作为哈希表键, 那么使用 eql 测试哈希表键的相等性是可以的:
* (setf (gethash 'bb h) 'aa) AA * (gethash 'bb h) AA ; T *
然而, 我们看到 eql 不会匹配具有字符字符串值的键. 函数 make-hash-table 有可选的键参数, 其中之一允许我们使用字符串作为哈希键值:
(make-hash-table &key :test :size :rehash-size :rehash-threshold)
在这里, 我们只对第一个可选键参数 :test 感兴趣, 它允许我们在匹配哈希表键时使用函数 equal 来测试相等性. 例如:
* (defvar h2 (make-hash-table :test #'equal)) H2 * (setf (gethash "animal" h2) "dog") "dog" * (setf (gethash "parrot" h2) "Brady") "Brady" * (gethash "parrot" h2) "Brady" ; T *
能够枚举哈希表中的所有键值对通常很有用. 这里有一个简单的例子, 首先定义一个函数 my-print, 它接受两个参数, 一个键和一个值. 然后我们可以使用 maphash 函数来调用我们的新函数 my-print, 并将哈希表中的每个键值对传递给它:
* (defun my-print (a-key a-value) (format t "key: ~A value: ~A~%" a-key a-value)) MY-PRINT * (maphash #'my-print h2) key: parrot value: Brady key: animal value: dog NIL *
函数 my-print 应用于哈希表中的每个键/值对. 这里还有一些我们演示的其他有用的哈希表函数:
* (hash-table-count h2) 2 * (remhash "animal" h2) T * (hash-table-count h2) 1 * (clrhash h2) #S(HASH-TABLE EQUAL) * (hash-table-count h2) 0 *
函数 hash-table-count 返回哈希表中的键值对数量. 函数 remhash 可用于从哈希表中删除单个键值对. 函数 clrhash 通过删除哈希表中的所有键值对来清空哈希表.
4.9. 使用 Eval 求值 Lisp 形式
有趣的是, clrhash 和 remhash 是我们目前看到的第一个修改其任何参数的 Common Lisp 函数, 除了作为宏而非函数的 setq 和 setf.
我们已经看到如何在 Lisp repl 监听器中键入任意 Lisp 表达式, 然后它们会被求值. 我们将在 Input and Output 章节中看到 Lisp 函数 read 求值列表 (或形式), 实际上 Lisp repl 使用函数 read.
在本节中, 我们将使用函数 eval 在程序内部求值任意 Lisp 表达式. 作为一个简单的例子:
* (defvar x '(+ 1 2 3 4 5)) X * x (+ 1 2 3 4 5) * (eval x) 15 *
使用函数 eval, 我们可以构建包含 Lisp 代码的列表, 并在我们自己的程序内部求值生成的代码. 我们得到了 “数据即代码” 的效果. 一个经典的 Lisp 程序, OPS5 专家系统工具, 将 Lisp 代码片段存储在网络数据结构中, 并使用函数 eval 执行存储在网络中的 Lisp 代码. 警告: 在非编译代码中使用 eval 可能效率低下. 为了效率, OPS5 程序包含了自己的 eval 版本, 该版本只解释网络中使用的 Lisp 子集.
4.10. 使用文本编辑器编辑 Lisp 源文件
我通常使用 Emacs, 但我们也将简要讨论编辑器 vi. 如果你使用 vi (例如, 输入 “vi nested.lisp”), 你首先应该做的是配置 vi, 以便在键入右括号时指示匹配的左括号; 你可以通过在 vi 运行时键入 “:set sm” 来完成此操作.
如果你选择学习 Emacs, 请在你的 .emacs 文件中 (如果你在 Windows 上运行, 则在你的主目录中的 _emacs 文件中) 输入以下内容:
(set-default 'auto-mode-alist (append '(("\\.lisp$" . lisp-mode) ("\\.lsp$" . lisp-mode) ("\\.cl$" . lisp-mode)) auto-mode-alist))
现在, 每当你打开扩展名为 “lisp”, “lsp” 或 “cl” (表示 “Common Lisp”) 的文件时, Emacs 将自动使用 Lisp 编辑模式. 我建议使用关键字 “Emacs tutorial” 在网上搜索以学习如何使用基本的 Emacs 编辑命令 - 我们在这里不会重复这些信息. 我使用免费软件工具进行我的专业 Lisp 编程: Emacs, SBCL, Clozure Common Lisp 和 Clojure. 我将在 Quicklisp 章节的最后部分向你展示如何配置 Emacs 和 Slime.
4.11. 从错误中恢复
当你在 Lisp repl 监听器中输入形式 (或表达式) 时, 你偶尔会犯错误并抛出错误. 以下是一个例子, 其中我没有显示输入 help 时错误抛出时的所有输出:
* (defun my-add-one (x) (+ x 1)) MY-ADD-ONE * (my-add-one 10) 11 * (my-add-one 3.14159) 4.14159 * (my-add-one "cat") debugger invoked on a SIMPLE-TYPE-ERROR: Argument X is not a NUMBER: "cat" Type HELP for debugger help, or (SB-EXT:EXIT) to exit from SBCL. restarts (invokable by number or by possibly-abbreviated name): 0: [ABORT] Exit debugger, returning to top level. (SB-KERNEL:TWO-ARG-+ "cat" 1) 0] help
调试提示符是方括号, 其中的数字指示当前的控制堆栈级别, 如果你递归地进入了调试器, 则指示你递归的深度.
…
进出调试器:
TOPLEVEL, TOP 退出调试器并返回到顶层 REPL
RESTART 调用编号所示的重启 (如果未给出则提示).
ERROR 打印错误条件和重启情况.
…
检查帧:
BACKTRACE [n] 显示堆栈向下 n 帧.
LIST-LOCALS, L 列出当前帧中的局部变量.
PRINT, P 显示当前帧的函数调用.
SOURCE [n] 显示帧的源形式以及 n 级封闭形式.
单步执行:
START 如果存在 CONTINUE 重启则选择它并开始单步执行. 单步执行仅影响使用高 DEBUG 优化质量编译的代码. 有关详细信息, 请参见用户手册.
STEP 单步进入当前形式.
NEXT 单步跳过当前形式.
OUT 暂时停止单步执行, 但当进入的最顶层帧返回时恢复执行.
STOP 停止单步执行.
…
0] list-locals
SB-DEBUG::ARG-0 = "cat"
SB-DEBUG::ARG-1 = 1
0] backtrace 2
Backtrace for: #<SB-THREAD:THREAD "main thread" RUNNING {1002AC32F3}>
0: (SB-KERNEL:TWO-ARG-+ "cat" 1)
1: (MY-ADD-ONE "cat")
0] :0
*
在这里, 我首先使用回溯命令 :bt 来打印导致错误的函数调用序列. 如果很明显错误在我正在处理的代码中的位置, 那么我就不费心使用回溯命令了. 然后我使用中止命令 :a 恢复到顶层 Lisp 监听器 (即回到大于号提示符). 有时, 你必须多次键入 :a 才能完全恢复到顶层大于号提示符.
4.12. 垃圾回收
像 Java 和 Python 等其他语言一样, Common Lisp 提供垃圾回收 (GC) 或自动内存管理.
简单来说, GC 发生在 Lisp 环境中释放不再能被任何全局变量 (或函数闭包, 我们将在下一章介绍) 访问的内存时. 如果一个全局变量 *variable-1* 首先被设置为一个列表, 然后如果我们稍后将 *variable-1* 设置为, 例如 nil, 并且如果原始列表中引用的数据没有被任何其他可访问的数据引用, 那么这个现在未使用的数据就受 GC 处理.
在实践中, Lisp 数据的内存是按时间顺序分批分配的, 短暂或分代垃圾回收器回收近期内存分配的频率远高于已分配较长时间的内存.
4.13. 快速加载你的工作环境
当你开始为大型项目使用 Common Lisp 时, 你可能需要在开始工作时加载许多文件到你的 Lisp 环境中. 大多数 Common Lisp 实现都有一个名为 defsystem 的函数, 其工作方式有点像 Unix 的 make 实用程序. 虽然我强烈推荐 defsystem 用于大型多人项目, 但我通常在我自己工作时使用更简单的方案: 我在每个我工作的项目的顶层目录中放置一个文件 loadit.lisp. 对于任何项目, 其 loadit.lisp 文件会加载所有源文件并初始化项目所需的任何全局数据.
本书的最后两章提供了配置为与 Quicklisp 一起工作的示例应用程序, 我们将在下一章中学习 Quicklisp.
另一个好技巧是创建一个包含所有项目的所有代码和数据的 Lisp 镜像. 在 NLP 章节的第一部分有一个这样的例子. 在这个例子中, 加载我的 NLP (自然语言处理) 库的代码和数据需要几分钟时间, 所以当我在使用它时, 我希望能够快速加载 SBCL Lisp 镜像.
所有 Common Lisp 实现都有一个用于转储包含代码和数据的工作镜像的机制.
4.14. 函数式编程概念
进行 Common Lisp 开发有两种主要风格. 面向对象编程得到了很好的支持 (请参阅关于 CLOS 的章节), 函数式编程也是如此. 简而言之, 函数式编程意味着我们应该编写没有副作用的函数. 首先让我给你一个带有副作用的非函数式例子:
(defun non-functional-example (car) (set-color car "red"))
这个使用 CLOS 的例子是非函数式的, 因为我们修改了函数参数的值. 像 Lisp Clojure 语言和 Haskell 语言这样的函数式语言不鼓励你修改函数的参数. 使用 Common Lisp, 你应该决定你喜欢哪种方法. 函数式编程意味着我们避免在函数内部维护状态, 并将数据视为不可变的 (即, 一旦创建对象, 就永远不会修改它). 我们可以修改最后一个例子, 使其成为函数式的: 在函数内部创建一个新的汽车对象, 复制作为对象传递的汽车的属性, 将新汽车对象的颜色更改为 “red”, 并返回新的汽车实例作为函数的值. 函数式编程可以防止许多类型的编程错误, 使单元测试更简单, 并使为现代多核 CPU 编程更容易, 因为只读对象本质上是线程安全的. Java 语言的现代最佳实践也倾向于使用不可变数据对象和函数式方法.
- Quicklisp
几十年来, 在开发 Lisp 系统时, 管理包和库是一个手动过程. 我过去常常将特定版本库的源代码打包作为我的 Common Lisp 项目的一部分. 早期的包管理系统 mk-defsystem 和 ASDF 非常有用, 但我并没有完全放弃将第三方库源代码与我的项目一起保存的做法, 直到 Zach Beane 创建了 Quicklisp 包系统20. 你需要安装 Quicklisp 才能使用本书后面的许多示例, 所以请花点时间按照 Quicklisp 网站上的说明立即安装它.
4.15. 使用 Quicklisp 查找包
我们将在后面的网络编程章节中需要 Common Lisp Hunchentoot 库, 所以我们现在将使用 Quicklisp 安装它, 作为开始使用 Quicklisp 的示例. 我们已经知道我们想要的包名, 但作为发现包的一个例子, 让我们开始使用 Quicklisp 搜索包名中包含 “hunchentoot” 的所有包:
* (ql:system-apropos "hunchentoot") #<SYSTEM clack-handler-hunchentoot / clack-20131111-git / quicklisp 2013-11-11> #<SYSTEM hunchentoot / hunchentoot-1.2.21 / quicklisp 2013-11-11> #<SYSTEM hunchentoot-auth / hunchentoot-auth-20101107-git / quicklisp 2013-11-11> #<SYSTEM hunchentoot-cgi / hunchentoot-cgi-20121125-git / quicklisp 2013-11-11> #<SYSTEM hunchentoot-dev / hunchentoot-1.2.21 / quicklisp 2013-11-11> #<SYSTEM hunchentoot-single-signon / hunchentoot-single-signon-20131111-git / quickl\ isp 2013-11-11> #<SYSTEM hunchentoot-test / hunchentoot-1.2.21 / quicklisp 2013-11-11> #<SYSTEM hunchentoot-vhost / hunchentoot-vhost-20110418-git / quicklisp 2013-11-11>
我们想要第 3 行看到的基础包, 我们可以按照以下示例安装基础包:
* (ql:quickload :hunchentoot) To load "hunchentoot": Load 1 ASDF system: hunchentoot ; Loading "hunchentoot" ....... (:HUNCHENTOOT)
在第 1 行, 我使用符号 :hunchentoot 来引用包名, 但使用字符串 “hunchentoot” 也可以. 第一次你使用 ql:quickload 加载一个库时, 你可能会看到额外的打印输出, 并且加载时间会更长, 因为源代码会从网上下载并缓存在本地目录 ~/.quicklisp/local-projects 中. 在本书的其余大部分内容中, 当我通过调用 ql:quickload 函数安装或使用包时, 我不会显示此函数在 repl 列表中的输出.
现在, 我们可以使用非常有用的 Common Lisp 函数 apropos 来查看刚刚安装的内容:
* (apropos "hunchentoot") HUNCHENTOOT::*CLOSE-HUNCHENTOOT-STREAM * (bound) HUNCHENTOOT:*HUNCHENTOOT-DEFAULT-EXTERNAL-FORMAT * (bound) HUNCHENTOOT::*HUNCHENTOOT-STREAM* HUNCHENTOOT:*HUNCHENTOOT-VERSION * (bound) HUNCHENTOOT:HUNCHENTOOT-CONDITION HUNCHENTOOT:HUNCHENTOOT-ERROR (fbound) HUNCHENTOOT::HUNCHENTOOT-OPERATION-NOT-IMPLEMENTED-OPERATION (fbound) HUNCHENTOOT::HUNCHENTOOT-SIMPLE-ERROR HUNCHENTOOT::HUNCHENTOOT-SIMPLE-WARNING HUNCHENTOOT::HUNCHENTOOT-WARN (fbound) HUNCHENTOOT:HUNCHENTOOT-WARNING HUNCHENTOOT-ASD:*HUNCHENTOOT-VERSION * (bound) HUNCHENTOOT-ASD::HUNCHENTOOT :HUNCHENTOOT (bound) :HUNCHENTOOT-ASD (bound) :HUNCHENTOOT-DEV (bound) :HUNCHENTOOT-NO-SSL (bound) :HUNCHENTOOT-TEST (bound) :HUNCHENTOOT-VERSION (bound) *
只要你正在考虑现在工具箱中新增的 Quicklisp 工具, 你就应该安装你在学习本书其余部分时需要的大部分包和库. 我将展示加载更多库所需的语句, 而不显示每个包加载时在 repl 中打印的输出:
(ql:quickload "clsql") (ql:quickload "clsql-postgresql") (ql:quickload "clsql-mysql") (ql:quickload "clsql-sqlite3") (ql:quickload :drakma) (ql:quickload :hunchentoot) (ql:quickload :cl-json) (ql:quickload "clouchdb") ;; for CouchDB access (ql:quickload "sqlite")
你需要先在系统上安装 Postgres 和 MySQL 客户端开发库, 才能使 clsql-postgresql 和 clsql-mysql 安装工作. 如果你不太可能将 Common Lisp 用于关系数据库, 那么你可以跳过安装 Postgres 和 MySQL 的工作. Knowledge Graph Navigator 章节中的示例使用 SQLite 数据库进行缓存. 你不需要为 sqlite 包安装任何额外的依赖项.
4.16. 使用 Quicklisp 配置 Emacs 和 Slime
我假设你的系统上已经安装了 Emacs. 在 repl 中你可以设置 Slime 包, 它允许 Emacs 连接到正在运行的 Lisp 环境:
(ql:quickload "quicklisp-slime-helper")
注意 repl 中的输出. 在我的系统上, 输出包含以下内容:
[package quicklisp-slime-helper] slime-helper.el installed in "/Users/markw/quicklisp/slime-helper.el" To use, add this to your ~/.emacs: (load (expand-file-name "~/quicklisp/slime-helper.el")) ;; Replace "sbcl" with the path to your implementation (setq inferior-lisp-program "sbcl")
如果你安装了 rlwrap 并定义了运行 SBCL 的别名, 请确保将 inferior lisp 程序设置为 SBCL 可执行文件的绝对路径; 在我的系统上, 我在我的 .emacs 文件中设置了以下内容:
(setq inferior-lisp-program "/Users/markw/sbcl/sbcl")
我不会介绍如何使用 Emacs 和 Slime, 网上有很多好的教程可供你阅读. 在后面的章节中, 我们将编写库和应用程序作为 Quicklisp 项目, 以便你能够加载自己的库, 从而更容易编写可以组合成更大应用程序的小型库.
5. 定义 Lisp 函数
在上一章中, 我们定义了一些简单的函数. 在本章中, 我们将讨论如何编写接受可变数量参数, 可选参数和关键字参数的函数.
特殊形式 defun 用于在 Lisp 源文件或顶层 Lisp 监听器提示符处定义新函数. 通常, 将函数定义放在源文件中并使用函数 load 将它们加载到我们的 Lisp 工作环境中是最方便的.
一般来说, 在 Lisp 函数内部使用全局变量是不好的形式. 相反, 我们更倾向于通过函数的参数列表将所有必需的数据传递给函数, 并通过函数返回的值 (或多个值) 来获取函数的结果. 注意, 如果我们确实需要全局变量, 习惯上用开头和结尾的 * 字符来命名它们; 例如:
(defvar *lexical-hash-table* (make-hash-table :test #'equal :size 5000))
然后在这个例子中, 如果你在函数定义内部看到变量 *lexical-hash-table*, 你至少会通过命名约定知道这是一个全局变量.
在第 1 章中, 我们看到了一个在函数定义内部使用词法作用域局部变量的例子 (在示例文件 nested.lisp 中).
定义函数可以接受的参数有几种选项. 介绍各种选项的最快方法是通过几个例子.
首先, 我们可以使用 &aux 关键字来声明函数定义中使用的局部变量:
* (defun test (x &aux y) (setq y (list x x)) y) TEST * (test 'cat) (CAT CAT) * (test 3.14159) (3.14159 3.14159)
使用 let 特殊运算符来定义辅助局部变量被认为是更好的编码风格; 例如:
* (defun test (x) (let ((y (list x x))) y)) TEST * (test "the dog bit the cat") ("the dog bit the cat" "the dog bit the cat") *
你可能不会经常使用 &aux, 但还有另外两个用于指定函数参数的选项: &optional 和 &key.
以下代码示例展示了如何使用可选函数参数. 注意, 可选参数必须出现在必需参数之后.
* (defun test (a &optional b (c 123)) (format t "a=~A b=~A c=~A~%" a b c)) TEST * (test 1) a=1 b=NIL c=123 NIL * (test 1 2) a=1 b=2 c=123 NIL * (test 1 2 3) a=1 b=2 c=3 NIL * (test 1 2 "Italian Greyhound") a=1 b=2 c=Italian Greyhound NIL *
在这个例子中, 可选参数 b 没有给定默认值, 所以如果未指定, 它将默认为 nil. 可选参数 c 给定了一个默认值 123.
我们已经看到了在内置 Lisp 函数中使用关键字参数的例子. 以下是如何在你的函数中指定关键字参数的示例:
* (defun test (a &key b c) (format t "a=~A b=~A c=~A~%" a b c)) TEST * (test 1) a=1 b=NIL c=NIL NIL * (test 1 :c 3.14159) a=1 b=NIL c=3.14159 NIL * (test "cat" :b "dog") a=cat b=dog c=NIL NIL *
5.1. 使用 Lambda 形式
定义匿名函数通常很有用. 我们可以使用 lambda 定义一个匿名函数; 例如, 让我们看一下示例文件 src/lambda1.lisp. 但首先, 我们将介绍 Common Lisp 函数 funcall, 它接受一个或多个参数; 第一个参数是一个函数, 任何剩余的参数都传递给绑定到第一个参数的函数. 例如:
* (funcall 'print 'cat) CAT CAT * (funcall '+ 1 2) 3 * (funcall #'- 2 3) -1 *
在这里的前两个对 funcall 的调用中, 我们只是引用了我们想要调用的函数名. 在第三个例子中, 我们使用了一个更好的表示法, 通过 #’ 来引用. 我们使用 #’ 字符来引用一个函数名.
考虑以下 repl 列表, 我们将看一下使用 ‘ 和使用 #’ 引用符号的主要区别:
$ ccl Clozure Common Lisp Version 1.12 DarwinX8664 ? 'barfoo531 BARFOO531 ? (apropos "barfoo") BARFOO531 ? #'bar987 > Error: Undefined function: BAR987
在第三行, 我们创建了一个新的符号 BARFOO531, 它是 interned 的, 正如你从查看包含字符串 “barfoo” 的所有 interned 符号所看到的那样. 第 7 行抛出一个错误, 因为 #’ 不会 intern 一个新符号.
以下是示例文件 src/lambda1.lisp:
(defun test () (let ((my-func (lambda (x) (+ x 1)))) (funcall my-func 1)))
在这里, 我们使用 lambda 定义了一个函数, 并将局部变量 my-func 的值设置为该匿名函数的值. 以下是函数 test 的输出:
* (test) 2 *
将函数用作数据的能力非常有用. 现在, 我们来看一个简单的例子:
* (defvar f1 #'(lambda (x) (+ x 1))) F1 * (funcall f1 100) 101 * (funcall #'print 100) 100 100
请注意, 第二次调用函数 testfn 打印了 “100” 两次: 第一次是调用函数 print 的副作用, 第二次是 testfn 的返回值 (函数 print 将其打印的内容作为其值返回).
5.2. 使用递归
稍后, 我们将看到如何使用特殊的 Common Lisp 宏来编程重复循环. 在本节中, 我们将使用递归来编码简单的循环, 并作为一种有效的方式来解决各种可以用递归自然表达的问题.
像往常一样, 本节的示例程序可以在 src 目录中找到. 在文件 src/recursion1.lisp 中, 我们看到了我们的第一个递归示例:
;; a simple loop using recursion (defun recursion1 (value) (format t "entering recursion1(~A)~%" value) (if (< value 5) (recursion1 (1+ value))))
这个例子很简单, 但对于讨论几点很有用. 首先, 注意函数 recursion1 只有在输入参数 “value” 小于 5 时才以其自身输入参数加一的值调用自身. 这个测试阻止了函数进入无限循环. 以下是一些示例输出:
* (load "recursion1.lisp") ;; Loading file recursion1.lisp ... ;; Loading of file recursion1.lisp is finished. T * (recursion1 0) entering recursion1(0) entering recursion1(1) entering recursion1(2) entering recursion1(3) entering recursion1(4) entering recursion1(5) NIL * (recursion1 -3) entering recursion1(-3) entering recursion1(-2) entering recursion1(-1) entering recursion1(0) entering recursion1(1) entering recursion1(2) entering recursion1(3) entering recursion1(4) entering recursion1(5) NIL * (recursion1 20) entering recursion1(20) NIL *
为什么第 24 行的调用没有通过递归循环? 因为输入参数不小于 5, 所以没有发生递归.
5.3. 闭包
我们已经看到函数可以接受其他函数作为参数, 并返回新函数作为值. 引用外部词法作用域变量的函数称为闭包. 示例文件 src/closure1.lisp 包含一个简单的例子:
(let* ((fortunes '("You will become a great Lisp Programmer" "The force will not be with you" "Take time for meditation")) (len (length fortunes)) (index 0)) (defun fortune () (let ((new-fortune (nth index fortunes))) (setq index (1+ index)) (if (>= index len) (setq index 0)) new-fortune)))
在这里, 函数 fortune 是在一个 let 形式内部定义的. 因为局部变量 fortunes 在函数 fortune 内部被引用, 所以变量 fortunes 在 let 形式求值后仍然存在. 理解这一点很重要, 通常在 let 形式内部定义的局部变量会 “超出作用域” 并且在 let 形式求值后不能再被引用.
然而, 在这个例子中, 除了调用函数 fortune 之外, 没有办法访问变量 fortunes 的内容. 至少, 闭包是隐藏变量的好方法. 以下是加载 src/closure1.lisp 文件并多次调用函数 fortune 的一些输出:
* (load "closure1.lisp") ;; Loading file closure1.lisp ... ;; Loading of file closure1.lisp is finished. T * (fortune) "You will become a great Lisp Programmer" * (fortune) "The force will not be with you" * (fortune) "Take time for meditation" * (fortune) "You will become a great Lisp Programmer" *
5.4. 使用函数 eval
在 Lisp 语言中, 我们常说代码即数据. 函数 eval 可以用来执行存储为 Lisp 数据的代码. 让我们看一个例子:
$ ccl Clozure Common Lisp Version 1.12 DarwinX8664 ? '(+ 1 2.2) (+ 1 2.2) ? (eval '(+ 1 2.2)) 3.2 ? (eval '(defun foo2 (x) (+ x x))) FOO2 ? (foo2 4) 8
亲爱的读者, 你多久会想使用 eval 取决于你. 在我使用 Lisp 语言的四十年里, 我对 eval 的主要用途是修改 Ops5 生产系统编程语言21 的标准版本, 以支持像多个数据世界以及派生新数据世界和移除它们的动作. Ops5 通过在一组产生式规则 (也称为 “专家系统”) 中找到共同表达式并将它们分解到一个网络 (如果你想查找的话, 是一个 Rete 网络), 其中规则中的共同表达式存储在一个地方. eval 在 Ops5 中被大量使用, 我用它来扩展 Ops5.
6. 定义 Common Lisp 宏
我们在上一章看到 Lisp 函数 eval 可以用来求值存储在列表中的任意 Lisp 代码. 因为 eval 效率低下, 所以自动生成 Lisp 代码的更好方法是定义在使用时内联展开的宏表达式.
在大多数 Common Lisp 系统中, 使用 eval 需要 Lisp 编译器即时编译一个形式, 这效率不高. 一些 Lisp 实现使用解释器来执行 eval, 这可能更快, 但如果解释器和编译后的
代码功能不完全相同, 则可能导致难以发现的错误.
为 Common Lisp 语言添加功能和语法的能力, 实际上是根据需要扩展语言的能力, 这确实是像 Common Lisp 和 Scheme 这样的语言的超能力.
6.1. 示例宏
文件 src/macro1.lisp 包含一个简单的宏和一个使用该宏的函数. 这个宏的例子有点刻意为之, 因为它本可以只是一个函数定义, 但它确实展示了创建和使用宏的过程. 我们使用 gensym
函数来定义一个新的唯一符号来引用一个临时变量:
;; first simple macro example: (defmacro double-list (a-list) (let ((ret (gensym))) `(let ((,ret nil)) (dolist (x ,a-list) (setq ,ret (append ,ret (list x x)))) ,ret))) ;; use the macro: (defun test (x) (double-list x))
在第 5 行开头看到的反引号字符用于以特殊方式引用列表: 列表中的任何内容在宏展开期间都不会被求值, 除非它紧跟在一个逗号字符之后. 在本例中, 我们指定 ,a-list 是因为我们希望
宏参数 a-list 的值被替换到特殊引用的列表中. 我们将在下一章详细介绍 dolist, 但现在只要理解 dolist 用于迭代列表的顶层元素就足够了, 例如:
* (dolist (x '("the" "cat" "bit" "the" "rat")) (print x)) "the" "cat" "bit" "the" "rat" NIL *
请注意, 示例宏 double-list 本身使用了宏 dolist. 以函数可以嵌套的方式嵌套宏是很常见的.
回到我们在文件 src/macro1.lisp 中的宏示例, 我们将尝试使用宏 double-list 的函数 test:
* (load "macro1.lisp") ;; Loading file macro1.lisp ... ;; Loading of file macro1.lisp is finished. T * (test '(1 2 3)) (1 1 2 2 3 3) *
6.2. 使用拼接运算符
另一个类似的例子在文件 src/macro2.lisp 中:
;; another macro example that uses ,@: (defmacro double-args (&rest args) `(let ((ret nil)) (dolist (x ,@args) (setq ret (append ret (list x x)))) ret)) ;; use the macro: (defun test (&rest x) (double-args x))
在这里, 拼接运算符 ,@ 用于在宏 double-args 中替换列表 args.
6.3. 使用 macroexpand-1
函数 macroexpand-1 用于将带有参数的宏转换为新的 Lisp 表达式. 例如:
* (defmacro double (a-number) (list '+ a-number a-number)) DOUBLE * (macroexpand-1 '(double n)) (+ N N) ; T *
编写宏是扩展 Lisp 语言的有效方法, 因为你可以控制传递给 Common Lisp 编译器的代码. 在两个宏示例文件中, 当定义函数 test 时, 宏展开在编译器处理代码之前完成. 我们将在下一章看到 Common Lisp 中包含的几个有用的宏.
我们只是 “浅尝辄止” 地了解了宏; 鼓励感兴趣的读者使用例如 “Common Lisp macros” 在网上搜索. 我特别推荐两本书深入探讨 Common Lisp 宏: Paul Graham 的 “On Lisp” 和 Doug Hoyte 的 “Let Over Lambda”. 两者都是深入的书籍, 会改变你体验软件开发的方式. 一个好的学习计划是花一年时间吸收 “On Lisp”, 然后再 tackling “Let Over Lambda”.
7. 使用 Common Lisp 循环宏
在本章中, 我们将讨论几个用于执行迭代的有用宏 (我们在第 2 章看到了如何使用递归进行迭代):
dolist– 处理列表元素的简单方法dotimes– 使用整数值循环变量进行迭代的简单方法do– 最通用的循环宏loop– 一个复杂的循环宏, 我几乎从不在我自己的代码中使用, 因为它看起来不像 “Lisp 风格”. 我在本书中不使用loop宏. 许多程序员确实喜欢loop宏, 所以你在阅读其他人的代码时可能会看到它.
7.1. dolist
我们在上一章看到了 dolist 的一个快速示例. dolist 宏的参数是:
(dolist (a-variable a-list [optional-result-value]) ...body... )
通常, dolist 宏返回 nil 作为其值, 但我们可以添加第三个可选参数, 该参数将作为生成表达式的值返回; 例如:
* (dolist (a '(1 2) 'done) (print a)) 1 2 DONE * (dolist (a '(1 2)) (print a)) 1 2 NIL *
dolist 宏的第一个参数是一个局部词法作用域变量. 一旦由 dolist 宏生成的代码执行完毕, 该变量就未定义了.
7.2. dotimes
当你需要一个带有整数循环索引的循环时, 使用 dotimes 宏. dotimes 宏的参数是:
(dotimes (an-index-variable max-index-plus-one [optional-result-value]) ...body... )
通常, dotimes 宏返回 nil 作为其值, 但我们可以添加一个第三个可选参数, 该参数将作为生成表达式的值返回; 例如:
* (dotimes (i 3 "all-done-with-test-dotimes-loop") (print i)) 0 1 2 "all-done-with-test-dotimes-loop" *
与 dolist 宏一样, 你通常会在 dotimes 宏内部使用 let 形式来声明额外的临时 (词法) 变量.
7.3. do
do 宏比 dotimes 或 dolist 更通用, 但使用起来更复杂. 以下是使用 do 循环宏的一般形式:
(do ((variable-1 variable-1-init-value variable-1-update-expression) (variable-2 variable-2-init-value variable-2-update-expression) . . (variable-N variable-N-init-value variable-N-update-expression)) (loop-termination-test loop-return-value) optional-variable-declarations expressions-to-be-executed-inside-the-loop)
有一个类似的宏 do*, 它类似于 let*, 因为循环变量的值可以依赖于先前声明的循环变量的值.
作为一个简单的例子, 这里有一个打印 0 到 3 整数的循环. 这个例子在文件 src/do1.lisp 中:
;; example do macro use (do ((i 0 (1+ i))) ((> i 3) "value-of-do-loop") (print i))
在这个例子中, 我们只声明了一个循环变量, 所以我们本可以使用更简单的 dotimes 宏.
这里我们加载文件 src/do1.lisp:
* (load "do1.lisp") ;; Loading file do1.lisp ... 0 1 2 3 ;; Loading of file do1.lisp is finished. T *
你会注意到我们没有看到 do 循环的返回值 (即字符串 “value-of-do-loop”), 因为我们正在求值的顶层形式是对函数 load 的调用; 我们确实看到了 load 的返回值打印出来. 如果我们手动在 Lisp 监听器中键入这个示例循环, 那么你会看到最终值 value-of-do-loop 被打印出来.
7.4. 使用 loop 特殊形式迭代向量或数组
我们之前使用 dolist 来迭代列表中的元素. 为了效率, 我们通常会使用向量 (一维数组), 我们可以使用 loop 来类似地处理向量:
(loop for td across testdata do (print td))))
其中 testdata 是一个一维数组 (向量), 在 do 块内部, 局部变量 td 被赋给向量中的每个元素.
- Common Lisp 包系统
在后面的章节中, 我们将看到两个定义为 Quicklisp 项目的完整应用程序: 关于 Knowledge Graph Creator 的章节和关于 Knowledge Graph Navigator 的章节. 另一个设置 Quicklib 项目的示例可以在 Plotting Data 章节中看到.
虽然这些后面的章节提供了将你自己的项目打包到包中的实际示例, 但这里的内容将为你提供你应该了解的一般背景信息.
在我们目前看到的简单示例中, 所有新创建的 Lisp 符号都已放置在默认包中. 你始终可以通过求值表达式 package 来检查当前包:
> *package* #<PACKAGE COMMON-LISP-USER> >
正如我们将在以下示例中使用的, 包 :cl 是 :common-lisp-user 的别名.
我们将定义一个新包 :my-new-package 以及包内的两个函数 foo1 和 foo2. 在此包外部, 假设它已加载, 我们可以使用 my-new-package:foo2 访问 foo2. foo1 未导出, 因此无法以这种方式访问. 但是, 如果我们想使用在另一个包中定义的符号, 我们可以始终以包名和两个冒号字符开头符号名, 因此我们可以使用 my-new-package::foo1. 使用 :: 允许我们访问未显式导出的符号.
当我在第 22 行离开包 :my-new-package 并返回到包 :cl 时, 尝试访问 my-new-package:foo1 会注意到抛出一个错误.
在第 3 行, 我们为包 :my-new-package 定义了别名 :p1, 并在第 44 行使用了这个别名. 以下示例的主要目的是我们在一个包中定义了两个函数, 但只导出了其中一个函数. 默认情况下, 另一个函数在新包外部不可见.
* (defpackage "MY-NEW-PACKAGE" (:use :cl) (:nicknames "P1") (:export :FOO2)) #<PACKAGE "MY-NEW-PACKAGE"> * (in-package my-new-package) #<PACKAGE "MY-NEW-PACKAGE"> * (defun foo1 () "foo1") FOO1 * (defun foo2 () "foo2") FOO2 * (foo1) "foo1" * (foo2) "foo2" * (in-package :cl) #<PACKAGE "COMMON-LISP"> * (my-new-package:foo2) "foo2" * (my-new-package:foo1) debugger invoked on a SB-INT:SIMPLE-READER-PACKAGE-ERROR in thread #<THREAD "main thread" RUNNING {1001F1ECE3}>: The symbol "FOO1" is not external in the MY-NEW-PACKAGE package. Stream: #<SYNONYM-STREAM :SYMBOL SB-SYS:*STDIN* {100001C343}> Type HELP for debugger help, or (SB-EXT:EXIT) to exit from SBCL. restarts (invokable by number or by possibly-abbreviated name): 0: [CONTINUE] Use symbol anyway. 1: [ABORT ] Exit debugger, returning to top level. * 1 * (p1:foo2) "foo2"
由于我们在 defpackage 表达式中指定了一个昵称, Common Lisp 允许在调用从包 :my-new-package 导出的函数 foo2 时使用该昵称 (在本例中是 P1).
在最后一个例子的末尾附近, 我们切换回默认包 COMMON-LISP-USER, 所以我们必须在第 42 行指定函数 foo2 的包名.
那么第 28 行的错误是怎么回事, my-new-package:foo1 未定义, 因为函数 foo1 未导出 (见第 4 行)? 事实证明, 你可以通过使用 :: 而不是单个 : 来轻松使用未从包导出的符号. 在这里, 它将被定义为: (my-new-package::foo1).
当你编写非常大的 Common Lisp 程序时, 将程序分解成不同的模块并将每个模块及其所有必需的数据放在不同的命名空间中 (通过创建新包) 是很有用的. 请记住, 所有符号, 包括变量, 生成的符号, CLOS 方法, 函数和宏都在某个包中.
对于小型包, 我有时会在文件顶部放置一个 defpackage 表达式, 紧随其后的是一个 in-package 表达式以切换到新包. 在一般情况下, 请像我在后面的 Knowledge Graph Creator 和 Knowledge Graph Navigator 章节中那样正确使用单独的 project 和 asdf 文件.
8. 输入和输出
我们将看到 Lisp 数据的输入和输出是使用流来处理的. 流是强大的抽象, 支持用于向终端, 文件, 套接字和字符串写入的通用函数库.
在所有情况下, 如果调用输入或输出函数时未指定流, 则输入流默认为 *standard-input*, 输出流默认为 *standard-output*. 这些默认流连接到我们在第 2 章中讨论的 Lisp 监听器. 在后面的 Knowledge Graph Navigator 章节中, 它支持用户界面, 我们将再次使用绑定到应用程序窗口不同滚动输出区域的输出流来写入颜色高亮的文本. 流的形式主义是通用的, 涵盖了许多常见的 I/O 用例.
8.1. Lisp read 和 read-line 函数
函数 read 用于读取一个 Lisp 表达式. 函数 read 在读取一个表达式后停止读取, 并忽略换行符. 我们将看一个使用示例 Lisp 程序 (在文件 read-test-1.lisp 中) 读取文件 test.dat 的简单示例. 这两个文件都可以在随本书附带的目录 src/code_snippets_for_book 中找到. 在 src 目录中启动你的 Lisp 程序. 文件 test.dat 的内容是:
1 2 3 4 "the cat bit the rat" read with-open-file
在函数 read-test-1 中, 我们使用宏 with-open-file 从文件中读取. 要向文件写入 (我们稍后会做), 我们可以使用关键字参数 :direction :output. 宏 with-open-file 的第一个参数是一个绑定到新创建的输入流 (或者如果我们正在写入文件, 则为输出流) 的符号; 然后可以在调用任何需要流参数的函数时使用此符号.
请注意, 我们用三个参数调用函数 read: 一个输入流, 一个标志指示如果出现 I/O 错误 (例如, 到达文件末尾) 是否应抛出错误, 第三个参数是如果到达文件 (或流) 末尾时函数 read 应返回的值. 当使用这三个参数调用 read 时, 要么返回文件 test.dat 中的下一个表达式, 要么在到达文件末尾时返回 nil 值. 如果我们确实到达了文件末尾, 局部变量 x 将被赋为 nil 值, 函数 return 将跳出 dotimes 循环. 使用宏 with-open-file 而不是使用 open 函数 (我们不会介绍) 的一个巨大优势是, 当离开由 with-open-file 宏生成的代码时, 文件流会自动关闭. 文件 read-test-1.lisp 的内容是:
(defun read-test-1 () "read a maximum of 1000 expressions from the file 'test.dat'" (with-open-file (input-stream "test.dat" :direction :input) (dotimes (i 1000) (let ((x (read input-stream nil nil))) (if (null x) (return)) ;; break out of the 'dotimes' loop (format t "next expression in file: ~S~%" x)))))
以下是你加载文件 read-test-1.lisp 并执行表达式 (read-test-1) 时将看到的输出:
* (load "read-test-1.lisp") ;; Loading file read-test-1.lisp ... ;; Loading of file read-test-1.lisp is finished. T * (read-test-1) next expression in file: 1 next expression in file: 2 next expression in file: 3 next expression in file: 4 next expression in file: "the cat bit the rat" NIL
注意: 字符串 “the cat bit the rat” 作为字符串打印 (带有引号), 因为我们在调用函数 format 的格式字符串中使用了 ~S 而不是 ~A.
在最后一个例子中, 我们将文件名作为字符串传递给宏 with-open-file. 这通常在所有操作系统上都不可移植. 相反, 我们可以创建一个路径名对象并传递它. 函数 pathname 可以接受八个不同的关键字参数, 但我们只在示例文件 read-test-2.lisp (在 src 目录中) 中使用最常见的两个. 以下列表仅显示了此示例与上一个示例之间的差异:
(let ((a-path-name (make-pathname :directory "testdata" :name "test.dat"))) (with-open-file (input-stream a-path-name :direction :input)
在这里, 我们指定了我们想要使用子目录 testdata 中的文件 test.dat. 注意: 我几乎从不使用路径名. 相反, 我使用字符串指定文件, 并使用字符 / 作为目录分隔符. 我发现这对于使用所有 Common Lisp 实现的 Macintosh, Windows 和 Linux 操作系统来说是可移植的.
文件 readline-test.lisp 与文件 read-test-1.lisp 相同, 只是我们调用函数 readline 而不是函数 read, 并且我们更改了输出格式消息以指示已读取整行文本:
(defun readline-test () "read a maximum of 1000 expressions from the file 'test.dat'" (with-open-file (input-stream "test.dat" :direction :input) (dotimes (i 1000) (let ((x (read-line input-stream nil nil))) (if (null x) (return)) ;; break out of the 'dotimes' loop (format t "next line in file: ~S~%" x)))))
当我们执行表达式 (readline-test) 时, 请注意输入文件第二行中包含的字符串的引号字符已被转义:
* (load "readline-test.lisp") ;; Loading file readline-test.lisp ... ;; Loading of file readline-test.lisp is finished. T * (readline-test) next line in file: "1 2 3" next line in file: "4 \"the cat bit the rat\"" NIL *
我们还可以从字符串的内容创建输入流. 文件 read-from-string-test.lisp 与示例文件 read-test-1.lisp 非常相似, 只是我们使用了宏 with-input-from-string (注意我是如何在测试字符串内部转义引号字符的):
(defun read-from-string-test () "read a maximum of 1000 expressions from a string" (let ((str "1 2 \"My parrot is named Brady.\" (11 22)")) (with-input-from-string (input-stream str) (dotimes (i 1000) (let ((x (read input-stream nil nil))) (if (null x) (return)) ;; break out of the 'dotimes' loop (format t "next expression in string: ~S~%" x))))))
当我们加载文件 read-from-string-test.lisp 时, 我们会看到以下输出:
* (load "read-from-string-test.lisp") ;; Loading file read-from-string-test.lisp ... ;; Loading of file read-from-string-test.lisp is finished. T * (read-from-string-test) next expression in string: 1 next expression in string: 2 next expression in string: "My parrot is named Brady." next expression in string: (11 22) NIL *
我们已经看到了流抽象如何有助于在各种流数据上允许相同的操作. 在下一节中, 我们将看到这种通用性也适用于 Lisp 打印函数.
8.2. Lisp 打印函数
本节中我们将介绍的所有打印函数都接受一个可选的最后一个参数, 即输出流. 例外的是 format 函数, 它可以接受一个流值作为其第一个参数 (或 t 表示 *standard-output*, 或 nil 值表示 format 应返回一个字符串值).
以下是指定可选流参数的示例:
* (print "testing") "testing" "testing" * (print "testing" *standard-output*) "testing" "testing" *
函数 print 打印 Lisp 对象, 以便可以使用函数 read 将它们读回. 相应的函数 princ 用于打印供 “人类消费”. 例如:
* (print "testing") "testing" "testing" * (princ "testing") testing "testing" *
print 和 princ 都返回它们的第一个参数作为它们的返回值, 正如你在前面的输出中看到的那样. 注意 princ 也不打印换行符, 所以 princ 经常与 terpri 一起使用 (它也接受一个可选的流参数).
我们在这本书中也看到了许多使用 format 函数的例子. 以下是 format 的不同用法, 通过为其第一个参数指定 nil 值来构建字符串:
* (let ((l1 '(1 2)) (x 3.14159)) (format nil "~A~A" l1 x)) "(1 2)3.14159" *
我们还没有看到写入文件的例子. 在这里, 我们将使用 with-open-file 宏以及选项来写入文件并删除任何同名的现有文件:
(with-open-file (out-stream "test1.dat" :direction :output :if-exists :supersede) (print "the cat ran down the road" out-stream) (format out-stream "1 + 2 is: ~A~%" (+ 1 2)) (princ "Stoking!!" out-stream) (terpri out-stream))
以下是求值此表达式的结果 (即, 在 src 目录中新创建的文件 test1.dat 的内容):
% cat test1.dat "the cat ran down the road" 1 + 2 is: 3 Stoking!!
请注意, print 在打印其参数之前会生成一个换行符.
9. 绘制数据
我们将使用 Zach Beane 的 vecto 库22 来绘制数据, 并将结果写入文件. 理想情况下, 我们希望具有交互式绘图功能, 但出于本书的目的, 我需要支持所有 Common Lisp 实现和多种操作系统的组合. 交互式绘图库通常依赖于实现和操作系统. 我们将在后面的反向传播神经网络章节中使用我们开发的 plotlib 示例.
9.1. 实现库
这里的示例都包含在目录 src/plotlib 中, 并打包为可加载的 Quicklisp 库. 这个库将在后面的章节中使用.
当我在我的 macOS 笔记本电脑上工作时, 我会将输出的图形文件在预览应用程序中保持打开状态, 每当我在 REPL 中重新运行生成图形的程序时, 激活预览应用程序窗口会刷新图形显示.
以下列表显示了文件 plotlib.lisp, 它是 vecto Common Lisp 绘图库的一个简单包装器. 请注意, 我只实现了我后续示例中需要的 vecto 功能的包装器, 因此以下代码并非特别通用, 但应该足够容易为你扩展以满足你项目的特定需求.
9.2. 打包为 Quicklisp 项目
两个文件 src/plotlib/plotlib.asd src/plotlib/package.lisp 配置了该库. 文件 package.lisp 定义了所需的库 vecto 并列出了从该库公共导出的函数:
(defpackage #:plotlib (:use #:cl #:vecto) (:export save-png plot-fill-rect plot-frame-rect plot-size-rect plot-line plot-string plot-string-bold pen-width))
要运行此库提供的测试函数, 你需要加载该库并在导出的函数名前加上包名 plotlib:, 如此示例所示:
(ql:quickload "plotlib") (plotlib::test-plotlib "test-plotlib.png")
除了 package.lisp 文件, 我们还使用扩展名为 .asd 的文件:
(asdf:defsystem #:plotlib :description "Describe plotlib here" :author "mark.watson@gmail.com" :license "Apache 2" :depends-on (#:vecto) :components ((:file "package") (:file "plotlib")))
如果你指定了一个尚未下载到你计算机上的依赖项, Quicklisp 将为你安装该依赖项.
- Common Lisp 对象系统 - CLOS
CLOS 是第一个 ANSI 标准化的面向对象编程设施. 虽然我在我的 Common Lisp 程序中不像使用 Java 和 Smalltalk 时那样经常使用类和对象, 但很难想象一个没有定义和使用至少几个 CLOS 类的任何规模的 Common Lisp 程序.
本章的示例程序位于文件 src/loving_snippets/HTMLstream.lisp 中. 大约十年前, 我在一个为我的商业自然语言处理产品自动生成演示网页的演示中使用了这个 CLOS 类.
我们将从反向的方式开始讨论 CLOS, 首先看一个使用 HTMLstream 类的简短测试函数. 一旦我们看到如何使用这个示例 CLOS 类, 我们将通过详细讨论 HTMLstream 类的实现来介绍 CLOS 的一小部分内容, 最后在本章末尾看到一些更多的 CLOS 编程技术. 本书仅提供对 CLOS 的简要介绍; 鼓励感兴趣的读者在网上搜索 “CLOS tutorial”.
定义用于实现 CLOS 的宏和函数是 Common Lisp 的标准部分. Common Lisp 支持泛型函数, 即具有相同名称但通过不同参数类型区分的不同函数.
9.3. 使用 CLOS 类的示例
文件 src/loving_snippets/HTMLstream.lisp 在文件末尾包含一个简短的测试程序:
(defun test (&aux x) (setq x (make-instance 'HTMLstream)) (set-header x "test page") (add-element x "test text - this could be any element") (add-table x '(("<b>Key phrase</b>" "<b>Ranking value</b>") ("this is a test" 3.3))) (get-html-string x))
泛型函数 make-instance 接受以下参数:
make-instance class-name &rest initial-arguments &key ...
函数 test 中使用了四个泛型函数:
set-header- 需要初始化类并定义页面标题add-element- 用于插入定义任何类型 HTML 元素的字符串add-table- 接受一个列表的列表, 并使用列表数据构造一个 HTML 表格get-html-string- 关闭流并返回所有生成的 HTML 数据作为字符串
在函数 test 中首先要注意的是, 调用这些泛型函数中的每一个的第一个参数都是类 HTMLstream 的一个实例. 你也可以自由地定义一个函数, 例如, add-element, 它不接受类 HTMLstream 的实例作为第一个函数参数, 对 add-element 的调用将被正确地路由到正确的函数定义.
我们将看到宏 defmethod 的作用类似于 defun, 不同之处在于它还允许我们定义许多具有相同函数名的方法 (即类的函数), 这些方法通过不同的参数类型和可能不同的参数数量来区分.
9.4. HTMLstream 类的实现
类 HTMLstream 非常简单, 将作为 CLOS 编程的一个合理介绍. 稍后我们将看到使用多重继承的更复杂的类示例. 尽管如此, 这是一个很好的例子, 因为代码简单, 作者经常使用这个类 (一些证明它有用!). 本节中列出的代码片段都包含在文件 src/loving_snippets/HTMLstream.lisp 中. 我们使用宏 defclass 开始定义一个新类, 它接受以下参数:
defclass class-name list-of-super-classes list-of-slot-specifications class-specifications
HTMLstream 的类定义相当简单:
(defclass HTMLstream () ((out :accessor out)) (:documentation "Provide HTML generation services"))
在这里, 类名是 HTMLstream, 超类列表是一个空列表 (), slot 规范列表只包含一个名为 out 的 slot 的规范, 并且只有一个类规范: 一个文档字符串. Slot 类似于 Java 和 Smalltalk 等语言中的实例变量. 大多数 CLOS 类至少继承自一个超类, 但我们将在下一节中看到继承的例子. 只有一个 slot (或实例变量), 我们定义了一个与其 slot 名称同名的访问器变量. 这是我个人的偏好, 即将读/写访问器变量命名为与其 slot 名称相同.
方法 set-header 初始化此类的实例内部使用的字符串输出流. 此方法使用方便的宏 with-accessors, 它将一组局部变量绑定到一个或多个类 slot 访问器. 我们将列出整个方法, 然后讨论它:
(defmethod set-header ((ho HTMLstream) title) (with-accessors ((out out)) ho (setf out (make-string-output-stream)) (princ "<HTML><head><title>" out) (princ title out) (princ "</title></head><BODY>" out) (terpri out)))
关于 defmethod 首先要注意的有趣之处是参数列表: 有两个参数 ho 和 title, 但我们将参数 ho 约束为类 HTMLstream 的成员或 HTMLstream 的子类. 现在, 既然我们将类 HTMLstream 的实例传递给这个泛型函数 (或方法 – 我交替使用术语 “泛型函数” 和 “方法”), 我们希望能够访问为此类定义的 slot, 这似乎是合理的. 方便的宏 with-accessors 正是我们需要的, 用于在此类的泛型函数 (或方法) 内部获取对 slot 的读写访问权限. 在术语 ((out out)) 中, 第一个 out 是绑定到此类实例 ho 的名为 out 的 slot 值的局部变量. 在 with-accessors 宏内部, 我们现在可以使用 setf 将 slot 值设置为新的字符串输出流. 注意: 我们在本书中尚未介绍 Common Lisp 类型 string-output-stream, 但我们将在下一页解释其用法.
当对方法 set-header (带有 HTMLstream 实例和字符串标题的参数) 的调用完成时, 该实例的 slot 被设置为一个新的 string-output-stream, 并且 HTML 头部信息被写入新创建的字符串输出流. 注意: 这个字符串输出流现在可供在 set-header 之后调用的任何类方法使用.
在文件 src/loving_snippets/HTMLstream.lisp 中定义了几个方法, 但我们将只看其中的四个: add-H1, add-element, add-table 和 get-html-string. 其余方法与 add-H1 非常相似, 读者可以阅读源代码中的代码.
与方法 set-header 一样, 方法 add-H1 使用宏 with-accessors 来访问流输出流 slot 作为局部变量 out. 在 add-H1 中, 我们使用我们在 Input and Output 章节中讨论过的函数 princ 将 HTML 文本写入字符串输出流:
(defmethod add-H1 ((ho HTMLstream) some-text) (with-accessors ((out out)) ho (princ "<H1>" out) (princ some-text out) (princ "</H1>" out) (terpri out)))
方法 add-element 与 add-H1 非常相似, 不同之处在于作为第二个参数传递的字符串 element 直接写入流输出流 slot:
(defmethod add-element ((ho HTMLstream) element) (with-accessors ((out out)) ho (princ element out) (terpri out)))
方法 add-table 将一个列表的列表转换为 HTML 表格. Common Lisp 函数 princ-to-string 是一个有用的实用函数, 用于将任何变量的值写入字符串. 函数 string-left-trim 和 string-right-trim 是字符串实用函数, 它们接受两个参数: 一个字符列表和一个字符串, 并分别从字符串的左侧或右侧移除这些字符. 注意: 另一个接受相同参数的类似函数是 string-trim, 它从字符串的前面 (左) 和后面 (右) 移除字符. 这三个函数都不修改第二个字符串参数; 它们返回一个新的字符串值. 以下是 add-table 方法的定义:
(defmethod add-table ((ho HTMLstream) table-data) (with-accessors ((out out)) ho (princ "<TABLE BORDER=\"1\" WIDTH=\"100\%\">" out) (dolist (d table-data) (terpri out) (princ " <TR>" out) (terpri out) (dolist (w d) (princ " <TD>" out) (let ((str (princ-to-string w))) (setq str (string-left-trim '(#\() str)) (setq str (string-right-trim '(#\)) str)) (princ str out)) (princ "</TD>" out) (terpri out)) (princ " </TR>" out) (terpri out)) (princ "</TABLE>" out) (terpri out)))
方法 get-html-string 通过使用函数 get-output-stream-string 获取存储在字符串输出流 slot 中的字符串:
(defmethod get-html-string ((ho HTMLstream)) (with-accessors ((out out)) ho (princ "</BODY></HTML>" out) (terpri out) (get-output-stream-string out)))
CLOS 是一个用于面向对象编程的丰富框架, 提供了 Java, Ruby 和 Smalltalk 等语言中功能的超集. 在这个简短的生成 HTML 的 CLOS 示例中, 我仅仅触及了皮毛. 在本书后面的内容中, 每当你看到对 make-instance 的调用时, 这就表明我们正在使用 CLOS, 即使我没有特别提到 CLOS.
9.5. 使用 Defstruct 或 CLOS
你可能会从我自己的代码中注意到, 我使用 Common Lisp defstruct 宏来定义数据结构的频率高于我使用 CLOS. 用于创建 CLOS 类的 defclass 宏要灵活得多, 但对于简单的数据结构, 我发现使用 defstruct 更简洁. 在最简单的情况下, defstruct 可以只是新类型的名称, 后跟 slot 名称. 对于像 my-slot-1 这样的每个 slot, 会自动生成访问器函数. 这里有一个简单的例子:
$ ccl Clozure Common Lisp Version 1.12 DarwinX8664 ? (defstruct struct1 s1 s2) STRUCT1 ? (make-struct1 :s1 1 :s2 2) #S(STRUCT1 :S1 1 :S2 2) ? (struct1-s1 (make-struct1 :s1 1 :s2 2)) 1
我们在第 3 行定义了一个名为 struct1 的结构体, 它有两个 slot 名称 s1 和 s2, 在第 5 行展示了自动生成的构造函数 make-struct1 的用法, 在第 7 行展示了两个自动生成的访问器函数之一 struct1-s1. 访问器函数的名称由结构体名称和 slot 名称构成.
10. 启发式引导搜索
我们将搜索空间表示为图: 节点以及节点之间的链接. 下图显示了我们用作示例的简单图, 查找从节点 n1 到节点 n11 的路径:
以下示例代码使用启发式方法来确定从任何特定位置首先尝试哪个节点: 移动到空间上最接近目标节点的节点. 我们看到这种启发式方法并不总是能产生最高效的搜索, 但我们仍然会到达目标节点. 作为一个启发式方法不起作用的例子, 考虑当我们从图的左下角的节点 n1 开始时. 搜索算法可以将节点 n2 和 n4 添加到要搜索的节点列表中, 并且将首先搜索使用节点 n4, 因为 n4 比 n2 更接近目标节点 n11. 在这种情况下, 搜索最终需要回溯尝试路径 n1 到 n2. 尽管这个启发式方法未能减少搜索时间的例子存在, 但总的来说, 对于大型搜索空间 (即具有许多节点和边的图), 它可以显著减少搜索时间.
主函数 A*search 从第 5 行开始, 一直延伸到第 151 行, 因为所有搜索实用函数都嵌套 (词法作用域) 在主函数内部. 主函数 A*search 的实际代码在第 150 行和 151 行.
;; Perform a heuristic A * search between the start and goal nodes: ;; ;; Copyright 1990, 2017 by Mark Watson (defun A*search (nodes paths start goal &aux possible-paths best) (defun Y-coord (x) (truncate (cadr x))) (defun X-coord (x) (truncate (car x))) (defun dist-between-points (point1 point2) (let ((x-dif (- (X-coord point2) (X-coord point1))) (y-dif (- (Y-coord point2) (Y-coord point1)))) (sqrt (+ ( * x-dif x-dif) ( * y-dif y-dif))))) (setq possible-paths (list (list (dist-between-points (eval start) (eval goal)) 0 (list start)))) (defun init-network () (setq paths (init-lengths paths)) (init-path-list nodes paths)) (defun init-lengths (pathlist) (let (new-path-list pathlength path-with-length) (dolist (path pathlist) (setq pathlength (slow-path-length path)) (setq path-with-length (append path (list pathlength))) (setq new-path-list (cons path-with-length new-path-list))) new-path-list)) (defun init-path-list (nodes paths) (dolist (node nodes) (setf (get node 'path-list) ;; let returns all paths connected to node: (let (path-list) (dolist (path paths) (if (equal node (start-node-name path)) (setq path-list (cons (list (end-node-name path) (path-length path)) path-list)) (if (equal node (end-node-name path)) (setq path-list (cons (list (start-node-name path) (path-length path)) path-list))))) path-list )))) (defun slow-path-length (path) (dist-between-points (start-node path) (end-node path))) (defun path-length (x) (caddr x)) (defun start-node (path) (eval (car path))) (defun end-node (path) (eval (cadr path))) (defun start-node-name (x) (car x)) (defun end-node-name (x) (cadr x)) (defun first-on-path (x) (caddr x)) (defun goal-node (x) (car x)) (defun distance-to-that-node (x) (cadr x)) (defun enumerate-children (node goal) (let* ((start-to-lead-node-dist (cadr node)) ;; distance already calculated (path (caddr node)) (lead-node (car path))) (if (get-stored-path lead-node goal) (consider-best-path lead-node goal path start-to-lead-node-dist) (consider-all-nodes lead-node goal path start-to-lead-node-dist)))) (defun consider-best-path (lead-node goal path distance-to-here) (let* ( (first-node (get-first-node-in-path lead-node goal)) (dist-to-first (+ distance-to-here (get-stored-dist lead-node first-node))) (total-estimate (+ distance-to-here (get-stored-dist lead-node goal))) (new-path (cons first-node path))) (list (list total-estimate dist-to-first new-path)))) (defun get-stored-path (start goal) (if (equal start goal) (list start 0) (assoc goal (get start 'path-list)))) (defun node-not-in-path (node path) (if (null path) t (if (equal node (car path)) nil (node-not-in-path node (cdr path))))) (defun consider-all-nodes (lead-node goal path start-to-lead-node-dist) (let (dist-to-first total-estimate new-path new-nodes) (dolist (node (collect-linked-nodes lead-node)) (if (node-not-in-path node path) (let () (setq dist-to-first (+ start-to-lead-node-dist (get-stored-dist lead-node node))) (setq total-estimate (+ dist-to-first (dist-between-points (eval node) (eval goal)))) (setq new-path (cons node path)) (setq new-nodes (cons (list total-estimate dist-to-first new-path) new-nodes))))) new-nodes)) (defun collect-linked-nodes (node) (let (links) (dolist (link (get node 'path-list)) (if (null (first-on-path link)) (setq links (cons (goal-node link) links)))) links)) (defun get-stored-dist (node1 node2) (distance-to-that-node (get-stored-path node1 node2))) (defun collect-ascending-search-list-order (a l) (if (null l) (list a) (if (< (car a) (caar l)) (cons a l) (cons (car l) (Collect-ascending-search-list-order a (cdr l)))))) (defun get-first-node-in-path (start goal) (let (first-node) (setq first-node (first-on-path (get-stored-path start goal))) (if first-node first-node goal))) (defun a*-helper () (if possible-paths (let () (setq best (car possible-paths)) (setq possible-paths (cdr possible-paths)) (if (equal (first (caddr best)) goal) best (let () (dolist (child (enumerate-children best goal)) (setq possible-paths (collect-ascending-search-list-order child possible-paths))) (a*-helper)))))) (init-network) (reverse (caddr (a*-helper)))) ;; Throw away test code: (defvar n1 '(30 201)) (defvar n2 '(25 140)) (defvar n3 '(55 30)) (defvar n4 '(105 190)) (defvar n5 '(95 110)) (defvar n6 '(140 22)) (defvar n7 '(160 150)) (defvar n8 '(170 202)) (defvar n9 '(189 130)) (defvar n10 '(200 55)) (defvar n11 '(205 201)) (print (A*search '(n1 n2 n3 n4 n5 n6 n7 n8 n9 n10 n11) ;; nodes '((n1 n2) (n2 n3) (n3 n5) (n3 n6) (n6 n10) ;; paths (n9 n10) (n7 n9) (n1 n4) (n4 n2) (n5 n8) (n8 n4) (n7 n11)) 'n1 'n11)) ;; starting and goal nodes
以下 repl 中的示例显示了我们看到的图搜索空间路径的计算.
$ sbcl * (load "astar_search.lisp") (N1 N2 N3 N6 N10 N9 N7 N11) T *
搜索类型有很多种: 像我们这里使用的广度优先搜索, 深度优先搜索, 使用启发式方法来优化依赖于搜索空间类型的搜索.
11. 网络编程
分布式计算无处不在: 你只需看看万维网, 互联网聊天等. 当然, 作为一名 Lisp 程序员, 你至少会想在 Lisp 中进行一些网络编程! 本书的先前版本提供了低级套接字网络编程示例.
对于这个新版本, 我决定删除那些示例, 转而鼓励你 “向食物链上游移动”, 并在对你可能正在开发的项目有意义的更高抽象级别上工作. 从 1980 年代开始, 我的很多工作都涉及用于 分布式网络应用程序的低级套接字编程. 在我写这篇文章时, 已经是 2013 年了, 有更好的方法来构建分布式应用程序.
具体来说, 由于本书后面的许多示例都从 web 和链接数据源获取信息, 我们将首先学习如何使用 Edi Weitz 的 Drakma HTTP 客户端库23. 为了有一个完整的客户端服务器示例, 我们还将简要了解 Edi Weitz 的 Hunchentoot web 服务器24, 它使用 JSON 作为数据序列化格式. 我过去常常使用 XML 进行数据序列化, 但 JSON 有许多优点: 更易于人 类阅读, 并且与 Javascript 代码以及支持 JSON 作为原生数据格式的 Postgres (新版本 9.x), MongoDB 和 CouchDB 等数据存储配合得很好.
本章前两节的代码片段源自 Drackma 和 Hunchentoot 文档中的示例.
11.1. Drakma 简介
Edi Weitz 的 Drakma 库25 支持通过 HTTP 请求获取数据. 正如你在 Drakma 文档中看到的, 你可以使用此库进行经过身份验证的 HTTP 请求 (即允许你访问需要登录的网站),
支持 HTTP GET 和 PUT 操作, 以及处理 cookie. 我们将使用的顶层 API 是 drakma:http-request, 它返回多个值. 在以下示例中, 我只想要前三个值, 并忽略其他值, 如获
取的原始 URI 和 IO 流对象. 我们使用内置的 Common Lisp 宏 multiple-value-setq:
* (ql:quickload :drakma) * (multiple-value-setq (data http-response-code headers) (drakma:http-request "http://baidu.com"))
我手动格式化了我在上一个 repl 列表中输入的最后一个语句, 并且我将继续在本书的其余部分手动编辑 repl 列表, 使它们更易于阅读.
以下显示了绑定到变量 data, http-response-code 和 headers 的一些数据:
* data "<!DOCTYPE html> <html> <head> <title>Mark Watson: Consultant and Author</title>
http-response-code 的值是 200, 这意味着没有错误:
* http-response-code 200
HTTP 响应头在许多应用程序中都很有用; 用于获取我的网站主页的头信息是:
* headers ((:SERVER . "nginx/1.1.19") (:DATE . "Fri, 05 Jul 2013 15:18:27 GMT") (:CONTENT-TYPE . "text/html; charset=utf-8") (:TRANSFER-ENCODING . "chunked") (:CONNECTION . "close") (:SET-COOKIE . "ring-session=cec5d7ba-e4da-4bf4-b05e-aff670e0dd10;Path=/"))
我们将在本书后面的几个示例中使用 Drakma. 在下一节中, 我们将使用 Hunchentoot 编写一个 web 应用程序, 并使用 Drakma 客户端对其进行测试.
11.2. Hunchentoot 简介
Edi Weitz 的 Hunchentoot 项目26 是一个用于编写 Web 应用程序和 Web 服务的灵活库. 在本节中, 我们还将使用 Edi 的 CL-WHO 库从 Lisp 代码生成 HTML. 在本节的示例代码中, 第一次快速加载 Hunchentoot 时, 它将被安装:
(ql:quickload "hunchentoot")
在本节的 Hunchentoot 示例中, 我将只使用 easy handler 框架27. 在你试验了本节中的示例后, 我将让你阅读有关使用自定义 acceptors28 的文档. 以下代码适用于 SBCL 的多线程安装和单线程安装 (例如, OS X 上 SBCL 的某些默认安装):
(ql:quickload :hunchentoot) (ql:quickload :cl-who) (in-package :cl-user) (defpackage hdemo (:use :cl :cl-who :hunchentoot)) (in-package :hdemo) (defvar *h * (make-instance 'easy-acceptor :port 3000)) ;; define a handler with the arbitrary name my-greetings: (define-easy-handler (my-greetings :uri "/hello") (name) (setf (hunchentoot:content-type*) "text/html") (with-html-output-to-string (*standard-output * nil :prologue t) (:html (:head (:title "hunchentoot test")) (:body (:h1 "hunchentoot form demo") (:form :method :post (:input :type :text :name "name" :value name) (:input :type :submit :value "Submit your name")) (:p "Hello " (str name)))))) (hunchentoot:start *h*)
在第 5 行到第 9 行, 我们创建并使用了一个新包, 其中包括对在 Lisp 代码 (CL-WHO) 和 Hunchentoot 库中生成 HTML 的支持. 在第 11 行, 我们创建了一个 easy acceptor 的实例, 监听端口 3000, 它为提供 HTTP 服务提供了有用的默认行为.
Hunchentoot 宏 define-easy-handler 用于在第 15 行到第 28 行定义一个 HTTP 请求处理程序, 并将其添加到 easy acceptor 实例中. 第一个参数, 本例中的 my-greetings, 是一个任意名称, 关键字 :uri 参数提供了一个 URL 模式, easy acceptor 服务器对象使用该模式将请求路由到此处理程序. 例如, 当你在你的计算机上运行此示例时, 此 URL 路由模式将处理如下请求:
http://localhost:3000/hello
在第 17 行到第 28 行, 我们使用 CL-WHO 库为网页生成 HTML. 你可能猜到了, :html 生成网页的外部 <html></html> 标签. 第 19 行将生成如下 HTML:
<head> <title>hunchentoot test</title> </head>
第 22 行到第 27 行生成一个 HTML 输入表单, 第 28 行显示用户在输入字段中输入文本并单击提交按钮时生成的任何值. 注意 easy handler 定义中第 1 行参数 name 的定义. 如果未定义参数 name, 则将在第 28 行将 nil 值显示为空字符串.
你应该运行这个例子, 并在 web 浏览器中访问生成的网页, 输入文本, 提交等. 你也可以使用我们上一节看到的 Drakma 库来获取生成的页面 HTML. 以下是使用 Drakma 客户端库访问最后一个例子的代码片段:
* (drakma:http-request "http://127.0.0.1:3000/hello?name=Mark") "Hello Mark" 200 ((:CONTENT-LENGTH . "10") (:DATE . "Fri, 05 Jul 2013 15:57:22 GMT") (:SERVER . "Hunchentoot 1.2.18") (:CONNECTION . "Close") (:CONTENT-TYPE . "text/plain; charset=utf-8")) #<PURI:URI http://127.0.0.1:3000/hello?name=Mark> #<FLEXI-STREAMS:FLEXI-IO-STREAM {10095654A3}> T "OK"
我们将在下一节同时使用 Drackma 和 Hunchentoot.
11.3. 用于数据序列化的完整 REST 客户端服务器示例 (使用 JSON)
构建现代分布式系统的一种合理方法是编写 REST Web 服务, 为客户端应用程序提供 JSON 数据. 这些客户端应用程序可能是用 Javascript 编写的富 Web 应用程序, 其他 Web 服务, 以及在智能手机上运行的应用程序, 它们从远程 Web 服务获取和保存数据.
我们将使用 cl-json Quicklisp 包将 Lisp 数据编码为表示 JSON 编码数据的字符串. 这里有一个快速示例:
* (ql:quickload :cl-json) * (defvar y (list (list '(cat . "the cat ran") '(dog . 101)) 1 2 3 4 5)) Y * y (((CAT . "the cat ran") (DOG . 101)) 1 2 3 4 5) * (json:encode-json-to-string y) "[{\"cat\":\"the cat ran\",\"dog\":101},1,2,3,4,5]"
以下列表显示了文件 src/web-hunchentoot-json.lisp 的内容:
(ql:quickload :hunchentoot) (ql:quickload :cl-json) (defvar *h * (make-instance 'hunchentoot:easy-acceptor :port 3000)) ;; define a handler with the name animal: (hunchentoot:define-easy-handler (animal :uri "/animal") (name) (print name) (setf (hunchentoot:content-type*) "text/plain") (cond ((string-equal name "cat") (json:encode-json-to-string (list (list '(average_weight . 10) '(friendly . nil)) "A cat can live indoors or outdoors."))) ((string-equal name "dog") (json:encode-json-to-string (list (list '(average_weight . 40) '(friendly . t)) "A dog is a loyal creature, much valued by humans."))) (t (json:encode-json-to-string (list () "unknown type of animal"))))) (hunchentoot:start *h*)
这个例子与上一节的 Web 应用程序例子非常相似. 不同之处在于此应用程序不打算在网页上查看, 因为它返回 JSON 数据作为 HTTP 响应. 第 8 行的 easy handler 定义指定了一个处理程序参数 name. 在第 12 行和 19 行, 我们检查参数 name 的值是否为 “cat” 或 “dog”, 如果是, 我们返回这些动物的相应 JSON 示例数据. 如果没有匹配项, 则从第 26 行开始的默认 cond 子句返回一个警告字符串作为 JSON 编码的字符串.
在运行此测试服务时, 在一个 repl 中, 你可以在另一个 repl 中使用 Drakma 库来测试它 (下一个列表中未显示所有输出):
* (ql:quickload :drakma) * (drakma:http-request "http://127.0.0.1:3000/animal?name=dog") "[{\"average_weight\":40, \"friendly\":true}, \"A dog is a loyal creature, much valued by humans.\"]" 200 * (drakma:http-request "http://127.0.0.1:3000/animal?name=cat") "[{\"average_weight\":10, \"friendly\":null}, \"A cat can live indoors or outdoors.\"]" 200
你可以使用 cl-json 库将包含 JSON 数据的字符串解码为 Lisp 数据:
* (ql:quickload :cl-json) To load "cl-json": Load 1 ASDF system: cl-json ; Loading "cl-json" . (:CL-JSON) * (cl-json:decode-json-from-string (drakma:http-request "http://127.0.0.1:3000/animal?name=dog")) (((:AVERAGE--WEIGHT . 40) (:FRIENDLY . T)) "A dog is a loyal creature, much valued by humans.")
对于我的大部分工作, REST Web 服务是 “只读” 的, 意思是客户端不修改服务器上的状态. 然而, 有些用例客户端应用程序可能想要这样做; 例如, 让客户端向上一个例子中添加新的动物.
(defparameter *animal-hash * (make-hash-table)) ;; handle HTTP POST requests: (hunchentoot:define-easy-handler (some-handler :uri "/add") (json-data) (setf (hunchentoot:content-type*) "text/plain") (let* ((data-string (hunchentoot:raw-post-data :force-text t)) (data (cl-json:decode-json-from-string json-data)) ;; assume that the name of the animal is a hashed value: (animal-name (gethash "name" data))) (setf (gethash animal-name *animal-hash*) data)) "OK")
在第 4 行, 我们定义了一个带有处理程序参数 json-data 的附加 easy handler. 这个数据假定是一个编码 JSON 数据的字符串, 在第 6 行和第 7 行被解码为 Lisp 数据. 我们将数据保存到全局变量 animal-hash.
在这个例子中, 我们将客户端发送的数据存储在一个内存中的哈希表中. 在实际应用程序中, 新数据可能会存储在数据库中.
11.4. 网络编程总结
你已经学习了编写 Web 服务和编写客户端使用 Web 服务的基础知识. 稍后, 我们将通过编写 Common Lisp 客户端来使用用 Python 编写的 Web 服务: 我们将包装重新训练的深度学习模型并从 Common Lisp 访问它们.
12. 使用 Microsoft Bing 搜索 API
多年来, 我一直使用 Bing 搜索 API. Microsoft Bing 支持多种商业搜索引擎服务, 包括我最喜欢的搜索引擎 Duck Duck Go. Bing 现在是 Azure 基础设施的一部分, 品牌为 “认知服务” (Cognitive Services). 你应该发现本章的示例代码相对容易扩展到你可能需要的其他 Azure 认知服务. 你需要注册 Microsoft 的 Azure 搜索服务才能使用本章中的材料. 你很可能将搜索视为一种以人类为中心的手动活动. 我希望扩展你的思维, 考虑那些自动化搜索, 在网络上查找信息并自动组织信息的应用程序. 虽然示例代码仅使用搜索 API, 但稍作修改即可扩展以使用 Azure 认知服务29 提供的所有 REST API, 其中包括: 分析文本以获取用户意图, 通用语言理解, 检测关键短语和实体名称, 语言之间翻译, 语音和文本之间转换, 以及各种计算机视觉服务. 这些服务对于每月几千次 API 调用通常是免费或成本非常低, 对于生产部署则成本会增加. 微软每年在 Azure 认知服务的研究和开发上花费约 10 亿美元.
12.1. 获取 Microsoft Bing 搜索 API 的访问密钥
如果你还没有 Azure 帐户, 则需要设置一个. 我经常使用 Bing 搜索 API 进行研究, 但我每月花费从未超过一美元左右, 通常我根本收不到账单. 对于个人使用来说, 这是一项非常便宜的服务. 你首先访问网页 https://azure.microsoft.com/en-us/try/cognitive-services/%5Bfn:33%5D 并注册一个访问密钥. 搜索 API 的注册目前在此网页表单的第四个选项卡中. 当你导航到搜索 API 选项卡时, 选择 Bing 搜索 API v7 选项. 你将获得一个 API 密钥, 你需要将其存储在一个环境变量中, 你很快就会需要它:
export BING_SEARCH_V7_SUBSCRIPTION_KEY=1e97834341d2291191c772b7371ad5b7
那不是我真正的订阅密钥! 你还需要将 Bing 搜索 API 设置为环境变量:
export BING_SEARCH_V7_ENDPOINT=https://api.cognitive.microsoft.com/bing/v7.0/search
12.2. 示例搜索脚本
我通常更喜欢使用在单独进程中运行的 curl 命令, 而不是使用纯 Common Lisp HTTP 客户端库. curl 实用程序处理所有可能的身份验证模式, 处理头信息, 多种格式的响应数据等. 我们将 curl 的输出捕获到一个字符串中, 然后由 JSON 库处理该字符串.
访问 Bing 搜索 API 只需要很少的 Common Lisp 代码. 函数 websearch 进行通用的 Web 搜索查询. 函数 get-wikidata-uri 通过在查询中添加 “site:wikidata.org” 并仅返回原始搜索词的 WikiData URI 来使用 websearch 函数. 我们稍后会看到几个例子. 我将列出整个库并附带注释:
(in-package #:bing) (defun get-wikidata-uri (query) (let ((sr (websearch (concatenate 'string "site:wikidata.org " query)))) (cadar sr))) (defun websearch (query) (let* ((key (uiop:getenv "BING_SEARCH_V7_SUBSCRIPTION_KEY")) (endpoint (uiop:getenv "BING_SEARCH_V7_ENDPOINT")) (command (concatenate 'string "curl -v -X GET \"" endpoint "?q=" (drakma:url-encode query :utf-8) "&mkt=en-US&limit=4\"" " -H \"Ocp-Apim-Subscription-Key: " key "\"")) (response (uiop:run-program command :output :string))) (with-input-from-string (s response) (let* ((json-as-list (json:decode-json s)) (values (cdadr (cddr (nth 2 json-as-list))))) (mapcar #'(lambda (x) (let ((name (assoc :name x)) (display-uri (assoc :display-url x)) (snippet (assoc :snippet x))) (list (cdr name) (cdr display-uri) (cdr snippet)))) values)))))
我们在第 8-9 行获取 Bing 访问密钥和搜索 API 端点. 第 10-16 行创建对 curl* 命令行实用程序的完整调用. 我们生成一个进程来运行 **curl 并将字符串输出捕获到第 17-18 行的变量 response 中. 你可能想添加一些打印语句来查看变量 command 和 response 的典型值. 响应数据是编码在字符串中的 JSON 数据, 第 19-28 行使用简单的代码解析出我们想要的值.
以下 repl 列表显示了此库的用法:
$ sbcl
This is SBCL 2.0.2, an implementation of ANSI Common Lisp.
* (ql:quickload "bing")
To load "bing":
Load 1 ASDF system:
bing
; Loading "bing"
..............
("bing")
* (bing:get-wikidata-uri "Sedona Arizona")
"https://www.wikidata.org/wiki/Q80041"
* (bing:websearch "Berlin")
(("Berlin - Wikipedia" "https://en.wikipedia.org/wiki/Berlin"
"Berlin (/ brln /; German: [blin] (listen)) is the capital and largest cit\
y of Germany by both area and population. Its 3,769,495 (2019) inhabitants make it t\
he most populous city proper of the European Union. The city is one of Germany's 16 \
federal states.")
("THE 15 BEST Things to Do in Berlin - 2020 (with Photos ..."
"https://www.tripadvisor.com/Attractions-g187323-Activities-Berlin.html"
"Book your tickets online for the top things to do in Berlin, Germany on Tripadvis\
or: See 571,599 traveler reviews and photos of Berlin tourist attractions. Find what\
to do today, this weekend, or in August. We have reviews of the best places to see \
in Berlin. Visit top-rated & must-see attractions.")
("Berlin - Official Website of the City of Berlin, Capital ..."
"https://www.berlin.de/en"
"Official Website of Berlin: Information about the Administration, Events, Culture\
, Tourism, Hotels and Hotel Booking, Entertainment, Tickets, Public Transport, Polit\
ical System, Local Authorities and Business in Berlin.")
("Berlin | History, Map, Population, Attractions, & Facts ..."
"https://www.britannica.com/place/Berlin"
"Berlin is situated about 112 miles (180 km) south of the Baltic Sea, 118 miles (1\
90 km) north of the Czech-German border, 110 miles (177 km) east of the former inner\
-German border, and 55 miles (89 km) west of Poland. It lies in the wide glacial val\
ley of the Spree River, which runs through the centre of the city.")
("Berlin travel | Germany - Lonely Planet"
"https://www.lonelyplanet.com/germany/berlin"
"Welcome to Berlin Berlin's combo of glamour and grit is bound to mesmerise all th\
ose keen to explore its vibrant culture, cutting-edge architecture, fabulous food, i\
ntense parties and tangible history.")
("Berlin 2020: Best of Berlin, Germany Tourism - Tripadvisor"
"https://www.tripadvisor.com/Tourism-g187323"
"Berlin is an edgy city, from its fashion to its architecture to its charged polit\
ical history. The Berlin Wall is a sobering reminder of the hyper-charged postwar at\
mosphere, and yet the graffiti art that now covers its remnants has become symbolic \
of social progress.")
("Berlin 2020: Best of Berlin, OH Tourism - Tripadvisor"
"https://www.tripadvisor.com/Tourism-g50087-Berlin_Ohio-Vacations.html"
"Berlin Tourism: Tripadvisor has 11,137 reviews of Berlin Hotels, Attractions, and\
Restaurants making it your best Berlin resource.")
("Berlin (band) - Wikipedia" "https://en.wikipedia.org/wiki/Berlin_(band)"
"Berlin is the alias for vocalist Terri Nunn, as well as the American new wave ban\
d she fronts, having been originally formed in Orange County, California. The band g\
ained mainstream-commercial success with singles including \" Sex (I'm A...) \", \" \
No More Words \" and the chart-topping \" Take My Breath Away \" from the 1986 film \
Top Gun.")
("Berlin's official travel website - visitBerlin.de"
"https://www.visitberlin.de/en"
"Berlin's way to a metropolis 100 Years of Greater Berlin In 1920, modern Berlin w\
as born at one fell swoop. 8 cities, 59 rural communities and 27 manor districts uni\
te to form \"Greater Berlin\""))
*
多年来, 我一直使用 Bing 搜索 API. 它们是我应用程序构建工具包的标准组成部分.
12.3. 总结
你可以查看 Azure 站点上广泛的 Congitive Services30. 可用的 API 包括: 语言检测, 语音识别, 用于对象识别的视觉库, Web 搜索以及数据中的异常检测. 除了使用自动化的 Web 抓取来获取我的个人研究数据外, 我经常使用自动化的 Web 搜索. 我发现微软的 Azure Bing 搜索 API 是最方便使用的, 我喜欢为我使用的服务付费.
13. 访问关系数据库
从 Common Lisp 访问关系数据库有很好的选择. 就我个人而言, 我几乎总是使用 Postgres, 过去我使用原生外部客户端库或套接字接口来访问 Postgres. 最近, 我决定改用 CLSQL31, 它提供了一个用于访问 Postgres, MySQL, SQLite 和 Oracle 数据库的通用接口. github 上也有几个 CLSQL 的近期分支. 我们将在本书的示例中使用 CLSQL. 希望你在阅读 Quicklisp 章节时已经安装了 CLSQL 以及你用于项目的一个或多个数据库的后端. 对于某些数据库应用程序, 当我知道我将始终使用嵌入式 SQLite 数据库 (即我永远不想切换到 Postgres 或其他数据库) 时, 我只使用 sqlite 库, 正如我在 Knowledge Graph Navigator 章节中所做的那样. 如果你尚未安装 CLSQL, 请立即安装:
(ql:quickload "clsql")
你还需要安装一个或多个 CLSQL 后端, 具体取决于你使用的关系数据库:
(ql:quickload "clsql-postgresql") (ql:quickload "clsql-mysql") (ql:quickload "clsql-sqlite3")
目录 src/clsql_examples 包含本章的独立示例文件.
虽然我通常更喜欢手动编写 SQL 查询, 但软件开发中似乎普遍趋向于数据映射器或活动记录设计模式. CLSQL 为 CLOS 提供了对象关系映射 (ORM) 功能.
你需要创建一个名为 news 的新数据库才能跟上本章和本书后面的示例. 我将在本章的示例中使用 Postgres, 并使用以下命令创建一个新数据库 (我的帐户是 “markw”, 以下假设我已将 Postgres 配置为在从 “localhost” 访问数据库时不需要此帐户的密码):
-> ~ psql psql (9.1.4) Type "help" for help. markw=# create database news; CREATE DATABASE
我们将使用三个示例程序, 你可以在 github 上的 book 存储库的 src/clsql_examples 目录中找到它们:
clsql_create_news_schema.lisp用于在数据库 “news” 中创建表 “articles”clsql_write_to_news.lisp用于向表 “articles” 写入测试数据clsql_read_from_news.lisp用于从表 “articles” 读取数据
以下列表显示了文件 src/clsql_examples/clsql_create_news_schema.lisp 的内容:
(ql:quickload :clsql) (ql:quickload :clsql-postgresql) ;; Postgres connection specification: ;; (host db user password &optional port options tty). ;; The first argument to **clsql:connect* * is a connection ;; specification list: (clsql:connect '("localhost" "news" "markw" nil) :database-type :postgresql) (clsql:def-view-class articles () ((id :db-kind :key :db-constraints :not-null :type integer :initarg :id) (uri :accessor uri :type (string 60) :initarg :uri) (title :accessor title :type (string 90) :initarg :title) (text :accessor text :type (string 500) :nulls-ok t :initarg :text))) (defun create-articles-table () (clsql:create-view-from-class 'articles))
在这个 repl 列表中, 我们使用我们刚刚定义的函数 create-articles-table 创建数据库表 “articles”:
-> src git:(master) sbcl (running SBCL from: /Users/markw/sbcl) * (load "clsql_create_news_schema.lisp") * (create-articles-table) NOTICE: CREATE TABLE / PRIMARY KEY will create implicit index "article_pk" for table "articles" T *
以下列表显示了文件 src/clsql_examples/clsql_write_to_news.lisp 的内容:
(ql:quickload :clsql) (ql:quickload :clsql-postgresql) ;; Open connection to database and create CLOS class and database view ;; for table 'articles': (load "clsql_create_news_schema.lisp") (defvar *a1* (make-instance 'article :uri "http://test.com" :title "Trout Season is Open on Oak Creek" :text "State Fish and Game announced the opening of trout season")) (clsql:update-records-from-instance *a1*) ;; modify a slot value and update database: (setf (slot-value *a1 * 'title) "Trout season is open on Oak Creek!!!") (clsql:update-records-from-instance *a1*) ;; warning: the last statement changes the "id" column in the table
你应该在 repl 中加载文件 clsql_write_to_news.lisp 一次以创建测试数据. 以下列表显示了文件 clsql_read_from_news.lisp:
(ql:quickload :clsql) (ql:quickload :clsql-postgresql) ;; Open connection to database and create CLOS class and database view ;; for table 'articles': (load "clsql_create_news_schema.lisp") (defun pp-article (article) (format t "~%URI: ~S ~%Title: ~S ~%Text: ~S ~%" (slot-value article 'uri) (slot-value article 'title) (slot-value article 'text))) (dolist (a (clsql:select 'article)) (pp-article (car a)))
加载文件 clsql_read_from_news.lisp 会产生以下输出:
URI: "http://test.com" Title: "Trout season is open on Oak Creek!!!" Text: "State Fish and Game announced the opening of trout season" URI: "http://example.com" Title: "Longest day of year" Text: "The summer solstice is on Friday."
你还可以在查询中嵌入 SQL where 子句:
(dolist (a (clsql:select 'article :where "title like '%season%'")) (pp-article (car a)))
这将产生以下输出:
URI: "http://test.com" Title: "Trout season is open on Oak Creek!!!" Text: "State Fish and Game announced the opening of trout season"
在这个例子中, 我使用 SQL like 表达式来执行部分文本匹配.
13.1. 数据库总结
你学习了访问关系数据库的基础知识. 当我设计用于处理数据的新系统时, 我喜欢将我的 Common Lisp 代码视为纯函数式的: 我的 Lisp 函数接受它们不修改的参数并返回结果. 我喜欢避免副作用, 即改变全局状态. 当我确实需要处理可变状态 (或数据) 时, 我更倾向于将可变状态存储在外部数据库中. 当我使用 Haskell 函数式编程语言时, 我也使用同样的方法.
14. 使用 MongoDB, Solr NoSQL 数据存储
非关系数据存储通常用于不需要完整关系代数或必须扩展的应用程序.
MongoDB 示例代码位于文件 src/loving_snippets/mongo_news.lisp 中. Solr 示例代码位于子目录 src/solr_examples 中.
第五版说明: Common Lisp cl-mongo 库现在不支持 2.6 版本 (2016 年发布) 之后的 MongoDB 版本. 你可以为 macOS32 或 Linux33 安装旧版本的 MongoDB. 我在本节中保留了 MongoDB 示例, 但我不建议你将 cl-mongo 和 MongoDB 用于任何严肃的应用程序.
Brewer 的 CAP 定理指出, 一个由多个节点组成的分布式数据存储系统可以在以下三个保证中的两个方面保持健壮: 所有节点始终具有数据状态的一致 (Consistent) 视图, 如果并非所有节点都在运行则数据的通用可用性 (Availablity), 以及分区容错性 (Partition tolerance), 以便在系统部分因网络故障而不可用时客户端仍能与数据存储系统通信. 基本思想是不同的应用程序有不同的要求, 有时通过放宽其中一个要求来降低系统成本或提高可伸缩性是有意义的.
一个很好的例子是, 某些应用程序可能不需要事务 (第一个保证), 因为如果客户端有时获取到几秒钟前的旧数据并不重要.
MongoDB 允许你选择一致性 vs. 可用性 vs. 效率.
我介绍了 Solr 索引和搜索服务 (基于 Lucene), 一方面因为 Solr 索引文档存储是一种 NoSQL 数据存储类型, 另一方面因为我相信你会发现 Solr 对于构建系统非常有用, 如果你还没有使用它的话.
14.1. MongoDB
以下关于 MongoDB 的讨论仅基于我的个人经验, 因此我不会涵盖所有用例. 我曾将 MongoDB 用于:
- 小型 MongoDB 节点集群, 用于分析社交媒体数据, 主要是文本挖掘和情感分析. 在所有情况下, 对于每个应用程序, 我都使用一个写主节点运行 MongoDB (即, 我向这个节点写入数据但不用于读取) 和多个只读从节点. 每个从节点将与通常执行单个分析任务的服务器在同一台服务器上运行.
- 用于网络广告的多个非常大的独立集群. 面临的问题包括试图在数据中心之间保持某种程度的一致性. 在每个数据中心内都使用了副本集.
- 运行单个 MongoDB 节点实例, 用于低容量数据收集和分析.
MongoDB 的优势之一是它非常 “开发者友好”, 因为它支持即席文档模式和交互式查询. 我提到 MongoDB 允许你选择一致性 vs. 可用性 vs. 效率. 当你执行 MongoDB 写入时, 你可以指定构成 “成功写入” 的一些粒度, 要求在客户端收到写入成功的确认之前, 在特定数量的节点上执行写入. 这个要求会给每个写操作增加开销, 如果某些节点不可用, 则可能导致写入失败.
MongoDB 在线文档34 非常好. 你不必为了体验以下 Common Lisp 和 MongoDB 示例而阅读它, 但如果你在体验了这些示例后发现 MongoDB 很适合你的需求, 那么你应该阅读文档. 我通常自己安装 MongoDB, 但有时使用托管服务很方便. 有几个备受好评的服务, 我使用过 MongoHQ35.
目前没有官方的 Common Lisp 支持访问 MongoDB, 但 Alfons Haffmans 的 cl-mongo36 项目是一个有用的项目, 它将允许我们编写 Common Lisp 客户端应用程序并访问 MongoDB 的大部分功能.
文件 src/mongo_news.lisp 包含接下来三个会话中使用的示例代码.
14.1.1. 添加文档
以下 repl 列表显示了用于创建新文档, 向其添加元素 (属性) 以及将其插入 MongoDB 数据存储的 cl-mongo API:
(ql:quickload "cl-mongo") (cl-mongo:db.use "news") (defun add-article (uri title text) (let ((doc (cl-mongo:make-document))) (cl-mongo:add-element "uri" uri doc) (cl-mongo:add-element "title" title doc) (cl-mongo:add-element "text" text doc) (cl-mongo:db.insert "article" doc))) ;; add a test document: (add-article "http://test.com" "article title 1" "article text 1")
在这个例子中, 在保存之前向新文档添加了三个字符串属性.
14.1.2. 按属性获取文档
我们将首先获取并漂亮地打印集合 articles 中的所有文档, 并获取所有文章的嵌套列表, 其中内部嵌套列表是文档 URI, 标题和文本:
(defun print-articles () (cl-mongo:pp (cl-mongo:iter (cl-mongo:db.find "article" :all)))) ;; for each document, use the cl-mongo:get-element on ;; each element we want to save: (defun article-results->lisp-data (mdata) (let ((ret '())) ;;(print (list "size of result=" (length mdata))) (dolist (a mdata) ;;(print a) (push (list (cl-mongo:get-element "uri" a) (cl-mongo:get-element "title" a) (cl-mongo:get-element "text" a)) ret))) ret)) (defun get-articles () (article-results->lisp-data (cadr (cl-mongo:db.find "article" :all))))
这两个函数的输出如下所示:
* (print-articles) { "_id" -> objectid(99778A792EBB4F76B82F75C6) "uri" -> http://test.com/3 "title" -> article title 3 "text" -> article text 3 } { "_id" -> objectid(D47DEF3CFDB44DEA92FD9E56) "uri" -> http://test.com/2 "title" -> article title 2 "text" -> article text 2 } * (get-articles) (("http://test.com/2" "article title 2" "article text 2") ("http://test.com/3" "article title 3" "article text 3"))
14.1.3. 通过正则表达式文本搜索获取文档
通过重用上一节中定义的函数 article-results->lisp-data, 我们还可以使用匹配属性值的正则表达式来搜索 JSON 文档:
;; find documents where substring 'str' is in the title: (defun search-articles-title (str) (article-results->lisp-data (cadr (cl-mongo:iter (cl-mongo:db.find "article" (cl-mongo:kv "title" // TITLE ATTRIBUTE (cl-mongo:kv "$regex" str)) :limit 10))))) ;; find documents where substring 'str' is in the text element: (defun search-articles-text (str) (article-results->lisp-data (cadr (cl-mongo:db.find "article" (cl-mongo:kv "text" // TEXT ATTRIBUTE (cl-mongo:kv "$regex" str)) :limit 10))))
我设置了限制以返回最多十个文档. 如果不设置限制, 此示例代码仅返回一个搜索结果. 以下 repl 列表显示了调用函数 search-articles-text 的结果:
* (SEARCH-ARTICLES-TEXT "text") (("http://test.com/2" "article title 2" "article text 2") ("http://test.com/3" "article title 3" "article text 3")) * (SEARCH-ARTICLES-TEXT "3") (("http://test.com/3" "article title 3" "article text 3"))
我发现使用 MongoDB 在试验数据和代码时特别有效. 无模式 JSON 文档格式, 使用 mongo shell37 进行交互式查询, 以及易于使用的 Common Lisp 客户端库 (如 clouchdb) 将让你在短时间内试验大量想法. 以下列表显示了交互式 mongo shell 的使用. 数据库 news 是本章 MongoDB 示例中使用的数据库; 你会注意到我的笔记本电脑上还有其他项目的其他数据库:
-> src git:(master) mongo MongoDB shell version: 2.4.5 connecting to: test > show dbs kbsportal 0.03125GB knowledgespace 0.03125GB local (empty) mark_twitter 0.0625GB myfocus 0.03125GB news 0.03125GB nyt 0.125GB twitter 0.125GB > use news switched to db news > show collections article system.indexes > db.article.find() { "uri" : "http://test.com/3", "title" : "article title 3", "text" : "article text 3", "_id" : ObjectId("99778a792ebb4f76b82f75c6") } { "uri" : "http://test.com/2", "title" : "article title 2", "text" : "article text 2", "_id" : ObjectId("d47def3cfdb44dea92fd9e56") } >
此列表的第 1 行显示启动 mongo shell. 第 4 行显示如何列出数据存储中的所有数据库. 在第 13 行, 我选择要使用的数据库 “news”. 第 15 行打印出当前数据库 “news” 中所有集合的名称. 第 18 行打印出 “articles” 集合中的所有文档. 你可以阅读 mongo shell38 的文档以了解更多选项, 如选择性查询, 添加索引等. 当你在笔记本电脑上运行 MongoDB 服务时, 还可以尝试 http://localhost:28017/%5Bfn:43%5D 上的管理界面.
14.2. Common Lisp Solr 客户端
Lucene 项目是使用最广泛的 Apache Foundation 项目之一. Lucene 是一个用于预处理和索引文本以及搜索文本的灵活库. 我个人在很多项目中使用过 Lucene, 很难数清有多少个. Apache Solr 项目39 为 Lucene 文本索引器和搜索引擎添加了网络接口. Solr 还为 Lucene 添加了其他实用功能:
- Lucene 是一个嵌入到你的程序中的库, 而 Solr 是一个完整的系统.
- Solr 为预处理和索引文本提供了良好的默认设置, 还为管理结构化数据提供了丰富的支持.
- 提供使用 HTTP 和 REST 的 XML 和 JSON API.
- 支持分面搜索, 地理空间搜索, 并提供用于在搜索结果的周围文本中高亮显示搜索词的实用程序.
- 如果你的系统用户数量增长到非常大, Solr 支持通过复制进行扩展.
我希望你会发现以下各节中的 Common Lisp 示例 Solr 客户端代码有助于你将 Solr 作为使用 Common Lisp 编写的大型系统的一部分.
14.2.1. 安装 Solr
下载二进制 Solr 发行版40 并解压此 Solr 发行版, cd 到发行版目录, 然后 cd 到示例目录并运行:
~/solr/example> java -jar start.jar
你可以通过 http://localhost:8983/solr/#/%5Bfn:46%5D 访问 Solr Admin Web App. 此 web 应用可以在以下屏幕截图中看到:
Solr 示例索引中还没有数据, 因此请按照 Solr 教程说明进行操作:
~/> cd ~/solr/example/exampledocs ~/solr/example/exampledocs> java -jar post.jar *.xml SimplePostTool version 1.5 Posting files to base url http://localhost:8983/solr/update using content-type application/xml.. POSTing file gb18030-example.xml POSTing file hd.xml POSTing file ipod_other.xml POSTing file ipod_video.xml POSTing file manufacturers.xml POSTing file mem.xml POSTing file money.xml POSTing file monitor.xml POSTing file monitor2.xml POSTing file mp500.xml POSTing file sd500.xml POSTing file solr.xml POSTing file utf8-example.xml POSTing file vidcard.xml 14 files indexed. COMMITting Solr index changes to http://localhost:8983/solr/update.. Time spent: 0:00:00.480
你将在后面的部分学习如何直接在你的 Common Lisp 程序中向 Solr 添加文档. 假设你的 Internet 连接速度很快, 因此下载 Solr 很快, 你应该花了不到五六分钟的时间来安装和运行 Solr, 并带有足够的示例搜索数据, 以便我们可以使用 Common Lisp 客户端示例进行试验. Solr 是一个用于存储, 索引和搜索数据的强大工具. 我建议你现在推迟阅读官方 Solr 文档, 而是先完成接下来两个部分的 Common Lisp 示例. 稍后, 如果你想使用 Solr, 则需要仔细阅读 Solr 文档.
14.3. Solr 的 REST 接口
Solr REST 接口文档41 记录了如何使用 HTTP GET 请求执行搜索. 我们需要做的就是在 Common Lisp 中实现这一点, 你会发现这很容易.
假设你正在运行 Solr 并且示例数据已加载, 我们可以尝试搜索包含例如 “British” 一词的文档, 使用 URL http://localhost:8983/solr/select?q=British%5Bfn:48%5D. 这是一个 REST 请求 URL, 你可以使用 curl 或 wget 等实用程序来获取 XML 数据. 我在 web 浏览器中获取了数据, 如以下 Firefox web 浏览器屏幕截图所示 (我喜欢 Firefox 格式化和显示 XML 数据的方式):
返回的搜索结果中的属性需要一些解释. 我们索引了几个示例 XML 数据文件, 其中一个包含我们刚刚看到的作为搜索结果的以下 XML 元素:
<doc> <field name="id">GBP</field> <field name="name">One British Pound</field> <field name="manu">U.K.</field> <field name="manu_id_s">uk</field> <field name="cat">currency</field> <field name="features">Coins and notes</field> <field name="price_c">1,GBP</field> <field name="inStock">true</field> </doc>
因此, 搜索结果具有与添加到 Solr 搜索索引的结构化 XML 数据相同的属性. Solr 索引结构化数据的能力仅仅是索引纯文本的超集. 例如, 如果我们正在索引新闻报道, 那么示例输入数据可能如下所示:
<doc> <field name="id">new_story_0001</field> <field name="title">Fishing Season Opens</field> <field name="text">Fishing season opens on Friday in Oak Creek.</field> </doc>
对于这个例子, 返回此文档作为结果的搜索结果将返回属性 id, title 和 text, 以及这三个属性的值.
默认情况下, Solr Web 服务返回 XML 数据, 如上一个屏幕截图所示. 对于我们的示例, 我更喜欢使用 JSON, 所以我们将始终向所有 REST 调用添加一个请求参数 wt=json. 以下屏幕截图显示了以 JSON 序列化格式而不是 XML 格式返回的相同数据, 使用 Chrome Web 浏览器 (我喜欢 Chrome 使用 JSONView Chrome 浏览器扩展程序格式化和显示 JSON 数据的方式):
你可以稍后阅读完整的 JSON REST Solr 文档, 但对于我们在此处的使用, 我们将使用以下搜索模式:
- http://localhost:8983/solr/select?q=British+One&wt=json - 搜索包含单词 “British” 或 “one” 中任一词的文档. 请注意, 在 URI 中, “+” 字符用于编码空格字符. 如果你想要一个 “+” 字符, 你需要用 “%2B” 对其进行编码, 空格字符编码为 “%20”. 默认的 Solr 搜索选项是搜索词的 OR, 这与例如 Google 搜索不同.
- http://localhost:8983/solr/select?q=British+AND+one&wt=json - 搜索同时包含单词 “British” 和 “one” 的文档. 纯文本中的搜索词是 “British AND one”.
14.3.1. 用于搜索的 Common Lisp Solr 客户端
正如我们在 Network Programming 中早些时候看到的, 使用 drakma 和 cl-json Common Lisp 库调用返回 JSON 数据的 REST 服务相当简单. 下一个列表 (所有 Solr 示例代码都在文件 src/solr-client.lisp 中) 中定义的函数 do-search 构造了一个我们在上一节看到的查询 URI, 并使用 Drackma 库执行 HTTP GET 操作, 以及使用 cl-json 库将返回的包含 JSON 数据的字符串解析为 Lisp 数据结构:
(ql:quickload :drakma) (ql:quickload :cl-json) (defun do-search (&rest terms) (let ((query-string (format nil "~{~A~^+AND+~}" terms))) (cl-json:decode-json-from-string (drakma:http-request (concatenate 'string "http://localhost:8983/solr/select?q=" query-string "&wt=json")))))
此示例代码确实以 Lisp 列表数据的形式返回搜索结果; 例如:
* (do-search "British" "one") ((:RESPONSE-HEADER (:STATUS . 0) (:*Q-TIME . 1) (:PARAMS (:Q . "British+AND+one") (:WT . "json"))) (:RESPONSE (:NUM-FOUND . 6) (:START . 0) (:DOCS ((:ID . "GBP") (:NAME . "One British Pound") (:MANU . "U.K.") (:MANU--ID--S . "uk") (:CAT "currency") (:FEATURES "Coins and notes") (:PRICE--C . "1,GBP") (:IN-STOCK . T) (:--VERSION-- . 1440194917628379136)) ((:ID . "USD") (:NAME . "One Dollar") (:MANU . "Bank of America") (:MANU--ID--S . "boa") (:CAT "currency") (:FEATURES "Coins and notes") (:PRICE--C . "1,USD") (:IN-STOCK . T) (:--VERSION-- . 1440194917624184832)) ((:ID . "EUR") (:NAME . "One Euro") (:MANU . "European Union") (:MANU--ID--S . "eu") (:CAT "currency") (:FEATURES "Coins and notes") (:PRICE--C . "1,EUR") (:IN-STOCK . T) (:--VERSION-- . 1440194917626281984)) ((:ID . "NOK") (:NAME . "One Krone") (:MANU . "Bank of Norway") (:MANU--ID--S . "nor") (:CAT "currency") (:FEATURES "Coins and notes") (:PRICE--C . "1,NOK") (:IN-STOCK . T) (:--VERSION-- . 1440194917631524864)) ((:ID . "0579B002") (:NAME . "Canon PIXMA MP500 All-In-One Photo Printer") (:MANU . "Canon Inc.") (:MANU--ID--S . "canon") (:CAT "electronics" "multifunction printer" "printer" "scanner" "copier") (:FEATURES "Multifunction ink-jet color photo printer" "Flatbed scanner, optical scan resolution of 1,200 x 2,400 dpi" "2.5\" color LCD preview screen" "Duplex Copying" "Printing speed up to 29ppm black, 19ppm color" "Hi-Speed USB" "memory card: CompactFlash, Micro Drive, SmartMedia, Memory Stick, Memory Stick Pro, SD Card, and MultiMediaCard") (:WEIGHT . 352.0) (:PRICE . 179.99) (:PRICE--C . "179.99,USD") (:POPULARITY . 6) (:IN-STOCK . T) (:STORE . "45.19214,-93.89941") (:--VERSION-- . 1440194917651447808)) ((:ID . "SOLR1000") (:NAME . "Solr, the Enterprise Search Server") (:MANU . "Apache Software Foundation") (:CAT "software" "search") (:FEATURES "Advanced Full-Text Search Capabilities using Lucene" "Optimized for High Volume Web Traffic" "Standards Based Open Interfaces - XML and HTTP" "Comprehensive HTML Administration Interfaces" "Scalability - Efficient Replication to other Solr Search Servers" "Flexible and Adaptable with XML configuration and Schema" "Good unicode support: héllo (hello with an accent over the e)") (:PRICE . 0.0) (:PRICE--C . "0,USD") (:POPULARITY . 10) (:IN-STOCK . T) (:INCUBATIONDATE--DT . "2006-01-17T00:00:00Z") (:--VERSION-- . 1440194917671370752)))))
我可能会修改搜索函数以仅返回获取到的文档列表, 丢弃返回的 Solr 元数据:
* (cdr (cadddr (cadr (do-search "British" "one")))) (((:ID . "GBP") (:NAME . "One British Pound") (:MANU . "U.K.") (:MANU--ID--S . "uk") (:CAT "currency") (:FEATURES "Coins and notes") (:PRICE--C . "1,GBP") (:IN-STOCK . T) (:--VERSION-- . 1440194917628379136)) ((:ID . "USD") (:NAME . "One Dollar") (:MANU . "Bank of America") (:MANU--ID--S . "boa") (:CAT "currency") (:FEATURES "Coins and notes") (:PRICE--C . "1,USD") (:IN-STOCK . T) (:--VERSION-- . 1440194917624184832)) ((:ID . "EUR") (:NAME . "One Euro") (:MANU . "European Union") (:MANU--ID--S . "eu") (:CAT "currency") (:FEATURES "Coins and notes") (:PRICE--C . "1,EUR") (:IN-STOCK . T) (:--VERSION-- . 1440194917626281984)) ((:ID . "NOK") (:NAME . "One Krone") (:MANU . "Bank of Norway") (:MANU--ID--S . "nor") (:CAT "currency") (:FEATURES "Coins and notes") (:PRICE--C . "1,NOK") (:IN-STOCK . T) (:--VERSION-- . 1440194917631524864)) ((:ID . "0579B002") (:NAME . "Canon PIXMA MP500 All-In-One Photo Printer") (:MANU . "Canon Inc.") (:MANU--ID--S . "canon") (:CAT "electronics" "multifunction printer" "printer" "scanner" "copier") (:FEATURES "Multifunction ink-jet color photo printer" "Flatbed scanner, optical scan resolution of 1,200 x 2,400 dpi" "2.5\" color LCD preview screen" "Duplex Copying" "Printing speed up to 29ppm black, 19ppm color" "Hi-Speed USB" "memory card: CompactFlash, Micro Drive, SmartMedia, Memory Stick, Memory Stick Pro, SD Card, and MultiMediaCard") (:WEIGHT . 352.0) (:PRICE . 179.99) (:PRICE--C . "179.99,USD") (:POPULARITY . 6) (:IN-STOCK . T) (:STORE . "45.19214,-93.89941") (:--VERSION-- . 1440194917651447808)) ((:ID . "SOLR1000") (:NAME . "Solr, the Enterprise Search Server") (:MANU . "Apache Software Foundation") (:CAT "software" "search") (:FEATURES "Advanced Full-Text Search Capabilities using Lucene" "Optimized for High Volume Web Traffic" "Standards Based Open Interfaces - XML and HTTP" "Comprehensive HTML Administration Interfaces" "Scalability - Efficient Replication to other Solr Search Servers" "Flexible and Adaptable with XML configuration and Schema" "Good unicode support: héllo (hello with an accent over the e)") (:PRICE . 0.0) (:PRICE--C . "0,USD") (:POPULARITY . 10) (:IN-STOCK . T) (:INCUBATIONDATE--DT . "2006-01-17T00:00:00Z") (:--VERSION-- . 1440194917671370752)))
如果你想向你的 Common Lisp 应用程序添加 Solr 搜索, 还有一些更重要的细节. 当搜索结果很多时, 你可能想要获取有限数量的结果, 然后 “分页” 浏览它们. 以下字符串可以添加到搜索查询的末尾:
&rows=2这个例子最多返回两个 “行” 或两个查询结果.&start=4这个例子跳过前 4 个可用的结果
一个结合了跳过结果和限制返回结果数量的查询看起来像这样:
http://localhost:8983/solr/select?q=British+One&wt=json&start=2&rows=2
14.3.2. 用于添加文档的 Common Lisp Solr 客户端
在上一个例子中, 我们依赖于使用设置新 Solr 安装的说明来向 Solr 搜索索引添加示例文档. 在实际应用程序中, 除了对索引文档执行搜索请求外, 你还需要从你的 Lisp 应用程序中添加新文档. 使用 Drakma, 我们将看到添加文档非常容易. 我们需要构造一些 XML, 其中包含以下形式的新文档:
<add> <doc> <field name="id">123456</field> <field name="title">Fishing Season</field> </doc> </add>
你可以指定应用程序所需的任何字段名称 (属性). 你也可以在一个添加请求中传递多个 <doc></doc> 元素. 我们希望以类似 Lisp 的方式指定文档: 一个由 cons 值组成的列表, 其中每个 cons 值是一个字段名和一个值. 对于最后一个 XML 文档示例, 我们希望有一个 API 让我们只处理像这样的 Lisp 数据:
(do-add '(("id" . "12345") ("title" . "Fishing Season")))
有一点需要注意: 属性名称和值必须作为字符串传递. 其他数据类型, 如整数, 浮点数, 结构体等将不起作用.
这比必须使用 XML 要好, 对吧? 我们首先需要一个函数将 cons 值列表转换为 XML. 我本可以使用 Quicklisp 中可用的 cxml 库中的 XML 构建器功能, 但对于这么简单的事情, 我只是用纯 Common Lisp 编写了它, 没有其他依赖项 (也在示例文件 src/solr-client.lisp 中):
(defun keys-values-to-xml-string (keys-values-list) (with-output-to-string (stream) (format stream "<add><doc>") (dolist (kv keys-values-list) (format stream "<field name=\"") (format stream (car kv)) (format stream "\">") (format stream (cdr kv)) (format stream "\"</field>")) (format stream "</doc></add>")))
列表第 2 行的宏 with-output-to-string 是我最喜欢的生成字符串的方式. 写入宏调用内部的变量 stream 的所有内容都附加到一个字符串; 这个字符串是宏的返回值.
以下函数将文档添加到 Solr 文档输入队列, 但实际上并不索引它们:
(defun do-add (keys-values-list) (drakma:http-request "http://localhost:8983/solr/update" :method :post :content-type "application/xml" :content ( keys-values-to-xml-string keys-values-list)))
你已经注意到第 3 行我正在访问在 localhost 上运行的 Solr 服务器, 而不是远程服务器. 在使用远程 Solr 服务器的应用程序中, 你需要修改此项以引用你的服务器; 例如:
"http://solr.knowledgebooks.com:8983/solr/update"
为了效率, Solr 不会立即将新文档添加到索引中, 直到你提交添加为止. 在你完成添加文档后, 应调用以下函数以实际将它们添加到索引中:
(defun commit-adds () (drakma:http-request "http://localhost:8983/solr/update" :method :post :content-type "application/xml" :content "<commit></commit>"))
请注意, 我们只需要一个空元素 <commit></commit> 来通知 Solr 服务器它应该索引所有最近添加的文档. 以下 repl 列表显示了所有内容协同工作 (我假设文件 src/solr-client.lisp 的内容已加载); 此列表中并未显示所有输出:
* (do-add '(("id" . "12345") ("title" . "Fishing Season"))) 200 ((:CONTENT-TYPE . "application/xml; charset=UTF-8") (:CONNECTION . "close")) #<PURI:URI http://localhost:8983/solr/update> #<FLEXI-STREAMS:FLEXI-IO-STREAM {1009193133}> T "OK" * (commit-adds) 200 ((:CONTENT-TYPE . "application/xml; charset=UTF-8") (:CONNECTION . "close")) #<PURI:URI http://localhost:8983/solr/update> #<FLEXI-STREAMS:FLEXI-IO-STREAM {10031F20B3}> T "OK" * (do-search "fishing") ((:RESPONSE-HEADER (:STATUS . 0) (:*Q-TIME . 2) (:PARAMS (:Q . "fishing") (:WT . "json"))) (:RESPONSE (:NUM-FOUND . 1) (:START . 0) (:DOCS ((:ID . "12345\"") (:TITLE "Fishing Season\"") (:--VERSION-- . 1440293991717273600))))) *
14.4. Common Lisp Solr 客户端总结
Solr 有许多我们在此处未使用的有用功能, 例如支持分面搜索 (在先前的搜索结果中向下钻取), 地理位置搜索以及按属性查找索引文档. 在我向你展示的示例中, 所有文本字段都被索引, 但 Solr 可选地允许你精细控制索引, 拼写校正, 词干提取等. Solr 是一个用于存储, 索引和搜索数据的非常有能力的工具. 我见过 Solr 在项目中有效地用作关系数据库或其他 NoSQL 数据存储 (如 CouchDB 或 MongoDB) 的替代品. 修改或删除 Solr 中数据的开销较高, 因此对于涉及频繁修改存储数据的应用程序, Solr 可能不是一个好的选择.
14.5. NoSQL 总结
使用比 Common Lisp 更方便的语言来访问 MongoDB. 说实话, 我最喜欢的是 Ruby 和 Clojure. 话虽如此, 对于 Common Lisp 的优势具有吸引力的应用程序来说, 了解你的 Common Lisp 应用程序可以很好地与 MongoDB 配合使用是件好事. 我是一个多语言程序员: 我喜欢为任何特定的工作使用最好的编程语言. 当我们设计和构建使用多种编程语言的系统时, 有几种共享数据的选项:
- 使用外部函数接口从一个进程内部调用另一种语言.
- 使用服务架构并使用 REST 或 SOAP 发送请求.
- 使用共享数据存储, 如关系数据库, MongoDB, CouchDB 和 Solr.
希望本章和上一章能为你提供最后一种选项所需的大部分内容.
15. 自然语言处理
自然语言处理 (NLP) 是对自然语言文本进行自动化处理, 有几个目标:
- 根据周围的词确定词语的词性 (POS 标注).
- 检测两个文本文档是否相似.
- 对文本进行分类 (例如, 是关于经济, 政治, 体育等).
- 总结文本.
- 确定文本的情感.
- 检测名称 (例如, 地名, 人名, 产品名等).
我们将使用我编写的一个库, 它可以执行 POS 标注, 分类 (classification), 摘要和检测专有名词. 本章的示例代码包含在位于以下子目录中的单独 Quicklisp 项目中:
src/fasttag: 执行词性标注并对文本进行分词.src/categorize_summarize: 执行分类 (例如, 检测文本主题是新闻, 政治, 经济等) 和文本摘要.src/kbnlp: 我的纯 Common Lisp 自然语言处理 (NLP) 代码的顶层 API. 在后面的章节中, 我们将采用不同的方法, 通过将 Python 深度学习模型作为 Web 服务来调用它们. 我在自己的工作中同时使用这两种方法.
我大约从 2001 年到 2011 年从事这个 Lisp 代码以及类似的 Java 代码的工作, 并在 2019 年再次为我的自动生成知识图谱数据的应用程序 (这是后面章节的一个例子) 工作. 我将从下一节开始, 快速解释如何运行示例代码. 如果你觉得这些示例很有趣, 那么你也可以阅读本章的其余部分, 我将在其中解释代码的工作原理.
我在我的库中用于分类 (词频统计) 的方法现在已经过时了. 我建议你考虑在免费的在线 Coursera 系统上学习 Andrew Ng 的机器学习课程, 然后再学习 Coursera NLP 课程之一, 以了解更现代的 NLP 处理方法.
除了我的库的代码之外, 你可能还会发现 src/linguistic_data 中的语言数据很有用.
15.1. 加载和运行 NLP 库
我将 NLP 示例代码重新打包成一个长文件. 代码过去分散在 18 个源文件中. 应从 src/kbnlp 目录加载代码:
% loving-common-lisp git:(master) > cd src/kbnlp % src/kbnlp git:(master) > sbcl * (ql:quickload "kbnlp") "Startng to load data...." "....done loading data." *
这也加载了 src/fasttag 和 src/categorize_summarize 中的项目.
不幸的是, 使用 SBCL 加载所需的语言数据大约需要一分钟, 所以我建议创建一个可以重新加载的 Lisp 镜像, 以避免加载数据所需的时间:
* (sb-ext:save-lisp-and-die "nlp-image" :purify t) [undoing binding stack and other enclosing state... done] [saving current Lisp image into nlp-image: writing 5280 bytes from the read-only space at 0x0x20000000 writing 3088 bytes from the static space at 0x0x20100000 writing 80052224 bytes from the dynamic space at 0x0x1000000000 done] % src git:(master) > ls -lh nlp-image -rw-r--r-- 1 markw staff 76M Jul 13 12:49 nlp-image
在这个 repl 列表的第 1 行, 我使用 SBCL 内置函数 save-lisp-and-die 来创建 Lisp 镜像文件. 每当设置你的工作环境需要一段时间时, 使用 save-lisp-and-die 是一个很好的技巧. 为下次在 Common Lisp 项目上工作时保存 Lisp 镜像让人想起在 Smalltalk 中工作, 在 Smalltalk 中你的工作保存在会话之间的镜像文件中.
注意: 我经常使用 Clozure-CL (CCL) 而不是 SBCL 来开发我的 NLP 库, 因为 CCL 加载我的数据文件比 CCL 快得多.
你现在可以使用你刚刚创建的 Lisp 镜像启动 SBCL, 其中预加载了 NLP 库和数据:
% src git:(master) > sbcl --core nlp-image * (in-package :kbnlp) #<PACKAGE "KBNLP"> * (defvar *x* (make-text-object "President Bob Smith talked to Congress about the economy and taxes")) *X* * *X* #S(TEXT :URL "" :TITLE "" :SUMMARY "<no summary>" :CATEGORY-TAGS (("news_politics.txt" 0.01648) ("news_economy.txt" 0.01601)) :KEY-WORDS NIL :KEY-PHRASES NIL :HUMAN-NAMES ("President Bob Smith") :PLACE-NAMES NIL :TEXT #("President" "Bob" "Smith" "talked" "to" "Congress" "about" "the" "economy" "and" "taxes") :TAGS #("NNP" "NNP" "NNP" "VBD" "TO" "NNP" "IN" "DT" "NN" "CC" "NNS") :STEMS #("presid" "bob" "smith" "talk" "to" "congress" "about" "the" "economi" "and" "tax")) *
在文件 src/knowledgebooks_nlp.lisp 的末尾注释中有一些测试代码, 它处理更多的文本, 以便也生成摘要; 如果你将测试代码加载到你的 repl 中, 你会看到一些输出:
(:SUMMARY "Often those amendments are an effort to change government policy by adding or subtracting money for carrying it out. The initial surge in foreclosures in 2007 and 2008 was tied to subprime mortgages issued during the housing boom to people with shaky credit. 2 trillion in annual appropriations bills for funding most government programs — usually low profile legislation that typically dominates the work of the House in June and July. Bill Clinton said that banking in Europe is a good business. These days homeowners who got fixed rate prime mortgages because they had good credit cannot make their payments because they are out of work. The question is whether or not the US dollar remains the world s reserve currency if not the US economy will face a depression." :CATEGORY-TAGS (("news_politics.txt" 0.38268) ("news_economy.txt" 0.31182) ("news_war.txt" 0.20174)) :HUMAN-NAMES ("President Bill Clinton") :PLACE-NAMES ("Florida"))
顶层函数 make-text-object 接受一个必需的参数, 该参数可以是一个包含文本的字符串, 也可以是一个字符串数组, 其中每个字符串是一个单词或标点符号. 函数 make-text-object 有两个可选的关键字参数: 找到文本的 URL 和标题.
(defun make-text-object (words &key (url "") (title "")) (if (typep words 'string) (setq words (words-from-string words))) (let* ((txt-obj (make-text :text words :url url :title title))) (setf (text-tags txt-obj) (part-of-speech-tagger words)) (setf (text-stems txt-obj) (stem-text txt-obj)) ;; note: we must find human and place names before calling ;; pronoun-resolution: (let ((names-places (find-names-places txt-obj))) (setf (text-human-names txt-obj) (car names-places)) (setf (text-place-names txt-obj) (cadr names-places))) (setf (text-category-tags txt-obj) (mapcar #'(lambda (x) (list (car x) (/ (cadr x) 1000000.0))) (get-word-list-category (text-text txt-obj)))) (setf (text-summary txt-obj) (summarize txt-obj)) txt-obj))
在第 2 行, 我们检查此函数是否使用包含文本的字符串调用, 在这种情况下, 函数 words-from-string 用于将文本分词为字符串标记数组. 第 2 行定义了局部变量 txt-obj, 其值为一个新的文本对象, 只有三个 slot (属性) 被定义: text, url 和 title. 第 4 行使用函数 part-of-speech-tagger 将 slot text-tags 设置为词性标记. 我们在第 8 行使用函数 find-names-places 来获取人名和地名, 并将这些值存储在文本对象中. 在第 11 行到 17 行, 我们使用函数 get-word-list-category 来设置文本对象中的类别. 在第 18 行, 我们类似地使用函数 summarize 来计算文本的摘要并将其存储在文本对象中. 我们将在本章的其余部分讨论这些 NLP 辅助函数.
函数 make-text-object 返回一个定义如下的结构体:
(defstruct text url title summary category-tags key-words key-phrases human-names place-names text tags stems)
15.2. 词性标注
这个标注器是我 FastTag 开源项目的 Common Lisp 实现. 我基于 Eric Brill 的博士论文 (1995) 创建了这个项目. 他使用机器学习在带标注的文本上学习标注规则. 我使用了他生成的最常用的标注规则子集, 当他测试他的标注器时. 我用 Lisp (以及 Ruby, Java 和 Pascal) 手动编码了他的规则. 我的标注器准确性较低, 但速度很快 - 因此得名 FastTag. 如果你只需要词性标注 (而不需要上一节使用的摘要, 分类和顶层 API), 你可以加载:
(ql:quickload "fasttag")
你可以在函数 part-of-speech-tagger 中找到标注器的实现. 我们已经在上一节看到了标注器的示例输出:
:TEXT #("President" "Bob" "Smith" "talked" "to" "Congress" "about" "the" "economy" "and" "taxes") :TAGS #("NNP" "NNP" "NNP" "VBD" "TO" "NNP" "IN" "DT" "NN" "CC" "NNS")
下表显示了标记的含义和一些示例词:
| Tag | Definition | Example words |
|---|---|---|
| CC | Coord Conjuncn | and, but, or |
| NN | Noun, sing. or mass | dog |
| CD | Cardinal number | one, two |
| NNS | Noun, plural | dogs, cats |
| DT | Determiner | the, some |
| NNP | Proper noun, sing. | Edinburgh |
| EX | Existential there | there |
| NNPS | Proper noun, plural | Smiths |
| FW | Foreign Word | mon dieu |
| PDT | Predeterminer | all, both |
| IN | Preposition | of, in, by |
| POS | Possessive ending | ’s |
| JJ | Adjective | big |
| PP | Personal pronoun | I, you, she |
| JJR | Adj., comparative | bigger |
| PP$ | Possessive pronoun | my, one’s |
| JJS | Adj., superlative | biggest |
| RB | Adverb | quickly |
| LS | List item marker | 1, One |
| RBR | Adverb, comparative | faster |
| MD | Modal | can, should |
| RBS | Adverb, superlative | fastest |
| RP | Particle | up, off |
| WP$ | Possessive-Wh | whose |
| SYM | Symbol | +, %, & |
| WRB | Wh-adverb | how, where |
| TO | “to” | to |
| $ | Dollar sign | $ |
| UH | Interjection | oh, oops |
| # | Pound sign | # |
| VB | verb, base form | eat, run |
| ” | quote | ” |
| VBD | verb, past tense | ate |
| VBG | verb, gerund | eating |
| ( | Left paren | ( |
| VBN | verb, past part | eaten |
| ) | Right paren | ) |
| VBP | Verb, present | eat |
| , | Comma | , |
| VBZ | Verb, present | eats |
| . | Sent-final punct | . ! ? |
| WDT | Wh-determiner | which, that |
| : | Mid-sent punct. | : ; — |
| WP | Wh pronoun | who, what |
函数 part-of-speech-tagger 遍历所有输入词, 并初步分配词典中指定的最可能的词性. 然后应用 Brill 规则的一个子集. 规则作用于当前词和前一个词.
作为一个规则的 Common Lisp 实现示例, 查找被标记为普通名词但以 “ing” 结尾的词, 因此它们应该是动名词 (动词形式):
; rule 8: convert a common noun to a present ; participle verb (i.e., a gerand) (if (equal (search "NN" r) 0) (let ((i (search "ing" w :from-end t))) (if (equal i (- (length w) 3)) (setq r "VBG"))))
你可以在文件 src/linguistic_data/FastTagData.lisp 中找到词典数据. 这个文件是 Lisp 代码而不是纯数据 (回想起来, 纯数据会更好, 因为加载速度会更快), 看起来像这样:
(defvar lex-hash (make-hash-table :test #'equal :size 110000)) (setf (gethash "shakeup" lex-hash) (list "NN")) (setf (gethash "Laurance" lex-hash) (list "NNP")) (setf (gethash "expressing" lex-hash) (list "VBG")) (setf (gethash "citybred" lex-hash) (list "JJ")) (setf (gethash "negative" lex-hash) (list "JJ" "NN")) (setf (gethash "investors" lex-hash) (list "NNS" "NNPS")) (setf (gethash "founding" lex-hash) (list "NN" "VBG" "JJ"))
我使用一个小的 Ruby 脚本从词典数据自动生成此文件. 注意, 单词可以有多个可能的词性. 单词最常见的词性是词典中的第一个条目.
15.3. 文本分类
对文本进行分类的代码相当简单, 使用了一种通常称为 “词袋” (bag of words) 的技术. 我收集了几个不同类别的样本文本, 对于每个类别 (如政治, 体育等), 我计算了单词对支持某个类别贡献的证据或权重. 例如, 单词 “president” 对于类别 “politics” 具有很强的权重, 但对于类别 “sports” 则没有. 原因在于单词 “president” 在关于政治的文章和书籍中频繁出现. 包含每个类别词权重的数据文件是 src/data/cat-data-tables.lisp. 你可以查看这个文件; 这是其中的一小部分:
如果你只需要分类而不需要本章开发的其他库, 你可以只加载这个库并在文件 categorize_summarize.lisp 底部的注释中运行示例:
({lang=”lisp”,linenos=off} (ql:quickload “categorize_summarize”) (defvar x “President Bill Clinton <<2 pages text no shown>> “) (defvar words1 (myutils:words-from-string x)) (print words1) (setq cats1 (categorize_summarize:categorize words1)) (print cats1) (defvar sum1 (categorize_summarize:summarize words1 cats1)) (print sum1)
让我们看一下实现, 从为存储每个类别或主题的词频数据创建哈希表开始:
;;; Starting topic: news_economy.txt (setf *h * (make-hash-table :test #'equal :size 1000)) (setf (gethash "news" *h*) 3915) (setf (gethash "debt" *h*) 3826) (setf (gethash "money" *h*) 1809) (setf (gethash "work" *h*) 1779) (setf (gethash "business" *h*) 1631) (setf (gethash "tax" *h*) 1572) (setf (gethash "poverty" *h*) 1512)
这个文件是由一个简单的 Ruby 脚本 (未包含在本书的示例代码中) 创建的, 该脚本处理子目录列表, 每个类别一个子目录. 以下列表显示了函数 get-word-list-category 的实现, 该函数计算输入文本的类别标签:
(defun get-word-list-category (words) (let ((x nil) (ss nil) (cat-hash nil) (word nil) (len (length words)) (num-categories (length categoryHashtables)) (category-score-accumulation-array (make-array num-categories :initial-element 0))) (defun list-sort (list-to-sort) ;;(pprint list-to-sort) (sort list-to-sort #'(lambda (list-element-1 list-element-2) (> (cadr list-element-1) (cadr list-element-2))))) (do ((k 0 (+ k 1))) ((equal k len)) (setf word (string-downcase (aref words k))) (do ((i 0 (+ i 1))) ((equal i num-categories)) (setf cat-hash (nth i categoryHashtables)) (setf x (gethash word cat-hash)) (if x (setf (aref category-score-accumulation-array i) (+ x (aref category-score-accumulation-array i)))))) (setf ss '()) (do ((i 0 (+ i 1))) ((equal i num-categories)) (if (> (aref category-score-accumulation-array i) 0.01) (setf ss (cons (list (nth i categoryNames) (round ( * (aref category-score-accumulation-array i) 10))) ss)))) (setf ss (list-sort ss)) (let ((cutoff (/ (cadar ss) 2)) (results-array '())) (dolist (hit ss) (if (> (cadr hit) cutoff) (setf results-array (cons hit results-array)))) (reverse results-array))))
需要注意的一点是, 在这个列表的第 11 行到 15 行, 我定义了一个嵌套函数 list-sort, 它接受一个子列表的列表, 并根据子列表中的第二个值 (是一个数字) 对子列表进行排序. 我经常在 “内部” 函数仅在 “外部” 函数中使用时嵌套函数.
第 2 行到第 9 行定义了外部函数中使用的几个局部变量. 全局变量 categoryHashtables 是一个词权重得分哈希表的列表, 每个类别一个. 局部变量 category-score-accumulation-array 初始化为一个在每个元素中包含数字零的数组, 并将用于 “记录” 每个类别的得分. 得分最高的类别将是外部函数的返回值.
第 17 行到 27 行是两个嵌套循环. 外部循环遍历输入词数组中的每个词. 内部循环遍历类别数量. 逻辑很简单: 对于每个词, 检查它在每个类别的词权重得分哈希表中是否有权重得分, 如果有, 则增加匹配类别的得分.
局部变量 ss 在第 28 行设置为空列表, 在第 29 行到 38 行的循环中, 我复制了得分超过阈值 0.01 的类别及其得分. 我们在第 39 行使用内部函数对 ss 中的列表进行排序, 然后返回得分大于中位数类别得分的类别.
15.4. 检测人名和地名
检测人名和地名的代码位于 src/kbnlp 中定义的包的顶层 API 代码中. 使用以下命令加载此包:
(ql:quickload "kbnlp") (kbnlp:make-text-object "President Bill Clinton ran for president of the USA")
支持识别文本中人名和地名的函数位于 Common Lisp 包 kb nlp:: 中:
find-names(words tags exclusion-list) –words是文本中单词的字符串数组,tags是词性标签 (来自 FastTag),exclusion-list是你想要排除在人名部分之外的单词数组. 找到的名称列表记录了名称在单词数组中的起始和结束索引.not-in-list-find-names-helper(a-list start end) – 如果找到的名称尚未添加到用于保存文本中人名的列表中, 则返回 true.find-places(words exclusion-list) – 这与find-names类似, 但它查找地名. 找到的地名列表记录了地名在单词数组中的起始和结束索引.not-in-list-find-places-helper(a-list start end) – 如果找到的地名尚未添加到用于保存文本中地名的列表中, 则返回 true.build-list-find-name-helper(v indices) – 这将起始/结束单词索引列表转换为包含名称的字符串.find-names-places(txt-object) – 这是你的应用程序将调用的顶层函数. 它接受一个defstruct text对象作为输入, 并通过添加它在文本中找到的人名和地名来修改defstruct text. 你在本章前面看到了这个例子.
我让你阅读代码, 只列出顶层函数:
(defun find-names-places (txt-object) (let* ((words (text-text txt-object)) (tags (text-tags txt-object)) (place-indices (find-places words nil)) (name-indices (find-names words tags place-indices)) (name-list (remove-duplicates (build-list-find-name-helper words name-indices) :test #'equal)) (place-list (remove-duplicates (build-list-find-name-helper words place-indices) :test #'equal))) (let ((ret '())) (dolist (x name-list) (if (search " " x) (setq ret (cons x ret)))) (setq name-list (reverse ret))) (list (remove-shorter-names name-list) (remove-shorter-names place-list))))
在第 2 行, 我们使用 slot 访问器 text-text 来获取文本对象的单词标记数组. 在第 3, 4 和 5 行, 我们对词性标签, 单词数组中的地名索引以及单词数组中的人名索引执行相同的操作.
在第 6 行到 11 行, 我们两次使用函数 build-list-find-name-helper 来根据单词数组中的索引构造人名和地名字符串. 我们还使用 Common Lisp 内置函数 remove-duplicates 来去除重复的名称.
在第 12 行到 16 行, 我们丢弃任何不包含空格的人名, 也就是说, 只保留至少包含两个单词标记的名称. 第 17 行到 19 行定义了函数的返回值: 使用函数 remove-shorter-names 两次来移除列表中相同名称的较短版本的人名和地名的列表的列表. 例如, 如果我们有两个名字 “Mr. John Smith” 和 “John Smith”, 那么我们会想从返回列表中删除较短的名字 “John Smith”.
15.5. 文本摘要
用于文本摘要的代码位于目录 src/categorize_summarize 中, 可以使用以下命令加载:
({lang=”lisp”,linenos=off} (ql:quickload “categorize_summarize”)
摘要代码依赖于我们之前看到的分类代码.
文本摘要有很多应用. 例如, 如果你正在编写一个文档管理系统, 你肯定会想使用像 Solr 这样的工具来提供搜索功能. Solr 会在索引文档字段值的片段中返回高亮匹配项. 使用摘要, 当你向 Solr (或其他) 搜索索引添加文档时, 你可以创建一个包含文档摘要的新的未索引字段. 然后, 当你系统的用户看到搜索结果时, 他们将看到他们习惯于在 Google, Bing 或 DuckDuckGo 搜索结果中看到的高亮匹配片段类型, 并且他们将看到文档的摘要.
听起来不错? 要解决的问题是获取好的文本摘要, 并且使用的技术可能需要根据你试图摘要的文本类型进行修改. 摘要有两种基本技术: 一种是几乎每个人都使用的实用方法, 另一个是我认为到目前为止几乎没有实际应用的研究领域. 这些技术是句子提取和将文本抽象成更短的形式 (通过组合和改变句子). 我们将使用句子提取.
我们如何选择从文本中提取哪些句子作为摘要? 我在 1999 年的想法很简单. بما أنني عادةً ما أقوم بتصنيف النص في خط أنابيب معالجة البرمجة اللغوية العصبية الخاص بي ، فلماذا لا أستخدم الكلمات التي أعطت أقوى دليل لتصنيف النص ، والعثور على الجمل التي تحتوي على أكبر عدد من هذه الكلمات. كمثال ملموس ، إذا قمت بتصنيف النص على أنه "سياسة" ، فأنا أحدد الكلمات في النص مثل "رئيس" ، "كونغرس" ، "انتخاب" ، وما إلى ذلك التي أدت إلى تصنيف "السياسة" ، وأجد الجمل التي تحتوي على أكبر تجمعات لهذه الكلمات.
摘要是你可能需要根据你的应用程序进行试验的东西. 我的旧摘要代码包含了很多特殊情况, 注释掉的代码块等. 我试图尽可能缩短和简化我旧的摘要代码以用于本书的目的, 同时仍然保持有用的功能.
用于摘要文本的函数相当简单, 因为当顶层 NLP 库函数 make-text-object 调用函数 summarize 时, 输入文本已经被分类了. 回想一下本章开头的例子, 类别数据如下所示:
:CATEGORY-TAGS (("news_politics.txt" 0.38268) ("news_economy.txt" 0.31182) ("news_war.txt" 0.20174))
此类别数据保存在以下列表第 4 行的局部变量 cats 中.
(defun summarize (txt-obj) (let* ((words (text-text txt-obj)) (num-words (length words)) (cats (text-category-tags txt-obj)) (sentence-count 0) best-sentences sentence (score 0)) ;; loop over sentences: (dotimes (i num-words) (let ((word (svref words i))) (dolist (cat cats) (let* ((hash (gethash (car cat) categoryToHash)) (value (gethash word hash))) (if value (setq score (+ score ( * 0.01 value (cadr cat))))))) (push word sentence) (if (or (equal word ".") (equal word "!") (equal word ";")) (let () (setq sentence (reverse sentence)) (setq score (/ score (1+ (length sentence)))) (setq sentence-count (1+ sentence-count)) (format t "~%~A : ~A~%" sentence score) ;; process this sentence: (if (and (> score 0.4) (> (length sentence) 4) (< (length sentence) 30)) (progn (setq sentence (reduce #'(lambda (x y) (concatenate 'string x " " y)) (coerce sentence 'list))) (push (list sentence score) best-sentences))) (setf sentence nil score 0))))) (setf best-sentences (sort best-sentences #'(lambda (x y) (> (cadr x) (cadr y))))) (if best-sentences (replace-all (reduce #'(lambda (x y) (concatenate 'string x " " y)) (mapcar #'(lambda (x) (car x)) best-sentences)) " ." ".") "<no summary>")))
第 8 行到 33 行的嵌套循环看起来有点复杂, 所以让我们逐步分析它. 我们的目标是为输入文本中的每个单词标记计算一个重要性得分, 然后选择一些包含高分单词的句子. 外部循环遍历输入文本中的单词标记. 对于每个单词标记, 我们遍历类别列表, 在每个类别哈希中查找当前单词, 并增加当前单词标记的
得分. 我们还会查找句子结束符并保存句子.
第 16 行到 32 行的代码有点复杂, 我在这里构建句子及其得分, 并将得分高于阈值 0.01 的句子存储在列表 best-sentences 中. 在两个嵌套循环之后, 在第 34 行到 44 行, 我们简单地按得分对句子进行排序, 并选择 “最佳” 句子作为摘要. 提取的句子不再保持其原始顺序, 这可能会产生奇怪的效果, 但我喜欢首先看到最相关的句子.
15.6. 文本挖掘
文本挖掘通常指在非结构化文本中查找数据. 我们在本章中已经介绍了几种文本挖掘技术:
- 命名实体识别 - 本章介绍的 NLP 库可识别人名和地名实体. 我将其留作练习, 供你扩展此库以处理公司和产品名称. 你可以从收集文件
src/kbnlp/linguistic_data/names/names.companies和src/kbnlp/data/names/names.products中的公司和产品名称开始, 并扩展库代码. - 文本分类 - 你可以通过添加更多支持类别的加权词/术语来提高分类的准确性. 如果你已经在你构建的系统中使用 Java, 我推荐 Apache OpenNLP 库, 它比我在我的 Common Lisp NLP 库中使用的更简单的 “词袋” 方法更准确. 如果你使用 Python, 那么我建议你也尝试 NLTK 库.
- 文本摘要.
在下一章中, 我将介绍另一个 “以数据为中心” 的主题: 在网络上执行信息收集. 你可能会发现能够使用 NLP 从非结构化文本创建结构化数据之间存在一些协同作用.
16. 信息收集
本章介绍使用数据源和通用技术在网络上收集信息, 我发现这些技术很有用. 当我计划这本新书的版本时, 我曾打算介绍一些使用 Common Lisp 进行语义网的基础知识, 基本上是提炼我之前出版的书 “Practical Semantic Web and Linked Data Applications, Common Lisp Edition” (2011 年出版) 中的一些数据. 然而, 由于该书现在有免费的 PDF 版本42, 我决定如果你对语义网和链接数据感兴趣, 就直接参考我以前的作品. 你也可以在我的网站上找到这本书的 Java 版本. 实时从网络收集信息有一些真正的优势:
- 你不必担心在本地存储数据.
- 信息是最新的 (取决于你选择使用的网络数据资源).
还有一些需要考虑的事情:
- 网络上的数据可能有使用限制, 所以请务必阅读你想要使用的网站的条款和条件.
- 数据的作者身份和有效性可能存在问题.
16.1. DBPedia 查询服务
维基百科是一个巨大的信息来源. 你可能知道, 你可以下载所有维基百科数据的转储43, 无论是否包含版本信息和评论. 当我想要快速访问整个维基百科英文文章集时, 我选择第二个选项, 只获取当前页面, 没有评论或版本信息. 这是当前维基百科文章的直接下载链接44. 这个 GZIP 文件中没有评论或用户页面. 数据量并不像你想象的那么大, 压缩后只有大约 9 GB, 未压缩时大约 42 GB. 要加载并运行一个例子, 试试:
(ql:quickload "dbpedia") (dbpedia:dbpedia-lookup "berlin")
维基百科是一个很好的手头资源, 但我将在本节向你展示如何访问维基百科的语义网版本, 即 DBPedia45, 使用 DBPedia 查询服务. 下面的代码列表显示了示例文件 dbpedia-lookup.lisp (位于目录 src/dbpedia 中) 的内容:
(ql:quickload :drakma) (ql:quickload :babel) (ql:quickload :s-xml) ;; utility from http://cl-cookbook.sourceforge.net/strings.html#manip: (defun replace-all (string part replacement &key (test #'char=)) "Returns a new string in which all the occurrences of the part is replaced with replacement." (with-output-to-string (out) (loop with part-length = (length part) for old-pos = 0 then (+ pos part-length) for pos = (search part string :start2 old-pos :test test) do (write-string string out :start old-pos :end (or pos (length string))) when pos do (write-string replacement out) while pos))) (defstruct dbpedia-data uri label description) (defun dbpedia-lookup (search-string) (let* ((s-str (replace-all search-string " " "+")) (s-uri (concatenate 'string "http://lookup.dbpedia.org/api/search.asmx/KeywordSearch?QueryString=" s-str)) (response-body nil) (response-status nil) (response-headers nil) (xml nil) ret) (multiple-value-setq (response-body response-status response-headers) (drakma:http-request s-uri :method :get :accept "application/xml")) ;; (print (list "raw response body as XML:" response-body)) ;;(print (list ("status:" response-status "headers:" response-headers))) (setf xml (s-xml:parse-xml-string (babel:octets-to-string response-body))) (dolist (r (cdr xml)) ;; assumption: data is returned in the order: ;; 1. label ;; 2. DBPedia URI for more information ;; 3. description (push (make-dbpedia-data :uri (cadr (nth 2 r)) :label (cadr (nth 1 r)) :description (string-trim '(#\Space #\NewLine #\Tab) (cadr (nth 3 r)))) ret)) (reverse ret))) ;; (dbpedia-lookup "berlin")
在这个示例代码中, 我只捕获了 DBPedia URI, 标签和描述的属性. 如果你取消注释第 41 行并查看对 DBPedia Lookup 的调用的整个响应体, 你可以看到你可能想要在你的应用程序中捕获的其他属性.
以下是对函数 dbpedia:dbpedia-lookup 的示例调用 (只显示了部分返回的数据):
* (ql:quickload "dbpedia") * (dbpedia:dbpedia-lookup "berlin") (#S(DBPEDIA-DATA :URI "http://dbpedia.org/resource/Berlin" :LABEL "Berlin" :DESCRIPTION "Berlin is the capital city of Germany and one of the 16 states of Germany. With a population of 3.5 million people, Berlin is Germany's largest city and is the second most populous city proper and the eighth most populous urban area in the European Union. Located in northeastern Germany, it is the center of the Berlin-Brandenburg Metropolitan Region, which has 5.9 million residents from over 190 nations. Located in the European Plains, Berlin is influenced by a temperate seasonal climate.") ...)
维基百科和维基百科的 DBPedia 链接数据是在线数据的重要来源. 如果你发挥创意, 你就能想出修改你构建的系统以从 DPPedia 拉取数据的方法. 一个警告: 语义网/链接数据源在网络上并非 100% 可用. 如果你的业务应用程序依赖于始终可用的 DBPedia, 那么你可以按照 DBPedia 网站46 上的说明在你自己的服务器上安装该服务.
16.2. 网络爬虫
当你编写网络爬虫从网络收集数据时, 需要考虑两件事:
- 确保阅读你想要使用其数据的网站的服务条款. 我发现打电话或发邮件给网站所有者, 解释我想要如何使用他们网站上的数据, 通常可以获得许可.
- 确保不要过于频繁地访问一个网站. 在获取页面和网站上的其他资产之间等待一两秒钟是礼貌的做法.
我们已经在本书中使用了 Drakma 网络客户端库. 请参阅文件 src/dbpedia/dbpedia-lookup.lisp (上一节介绍) 和 src/solr_examples/solr-client.lisp (在 NoSQL 章节中介绍). Paul Nathan 编写了一个使用 Drakma 爬取网站的库, 并提供了一个示例来打印找到的链接. 他的代码可在 AGPL 许可下从 articulate-lisp.com/src/web-trotter.lisp47 获得, 我建议将其作为起点.
我发现在开发过程中制作网站的本地副本有时更容易, 这样我就不必过度使用网站主机的资源. 假设你安装了 wget 实用程序, 你可以像这样镜像一个网站:
wget -m -w 2 http://knowledgebooks.com/ wget -mk -w 2 http://knowledgebooks.com/
这两个示例在 HTTP 资源请求之间都有两秒钟的延迟. 选项 -m 指示递归地跟踪网站上的所有链接. 选项 -w 2 在请求之间延迟两秒钟. 选项 -mk 将 URI 引用转换为本地镜像上的本地文件引用. 第 2 行的第二个示例更方便.
我们在输入和输出章节中介绍了从本地文件读取. 我使用的一个技巧是简单地将所有网页连接到一个文件中. 假设你创建了一个网站的本地镜像, cd 到顶层目录并使用类似以下的方法:
cd knowledgebooks.com
cat *.html */*.html > ../web_site.html
然后你可以打开该文件, 在 p, div, h1 等 HTML 元素中搜索文本, 以将整个网站作为一个文件处理.
16.3. 使用 Apache Nutch
Apache Nutch48, 像 Solr 一样, 是基于 Lucene 搜索技术构建的. 当我需要爬取网站并且想要一个带有良好搜索索引的本地副本时, 我将 Nutch 用作 “盒子里的搜索引擎”. Nutch 处理的开发者用例与我们在 NoSQL 章节中介绍的 Solr 不同. 正如我们所见, Solr 是一个用于将结构化数据作为文档进行索引和搜索的有效工具. 只需很少的设置, 就可以设置 Nutch 自动维护一个网站列表的最新索引, 并可选择从这些 “种子” 网站跟踪链接到某个期望的深度. 你可以使用我们用于 Solr 的相同 Common Lisp 客户端代码, 但有一个例外; 你需要将搜索服务的根 URI 更改为:
http://localhost:8080/opensearch?query=
因此, 修改后的客户端代码 src/solr_examples/solr-client.lisp 需要更改一行:
(defun do-search (&rest terms) (let ((query-string (format nil "~{~A~^+AND+~}" terms))) (cl-json:decode-json-from-string (drakma:http-request (concatenate 'string "http://localhost:8080/opensearch?query=" query-string "&wt=json")))))
Nutch 的早期版本安装和配置非常简单. Nutch 的后期版本更复杂, 性能更高, 服务更多, 但设置起来比早期版本需要更长的时间. 如果你只是想试验 Nutch, 你可能想从早期版本开始.
OpenSearch.org49 网站包含许多你可能想要尝试的公共 OpenSearch 服务. 如果你想修改 src/solr-client.lisp 中的示例客户端代码, 一个好的起点是返回 JSON 数据的 OpenSearch 服务, OpenSearch Community JSON formats 网页50 是一个不错的起点. 此网页上的一些服务, 如纽约时报服务, 要求你注册开发人员 API 密钥.
当我开始编写需要网络数据的应用程序时 (无论我使用哪种编程语言), 我首先会查找可能提供我所需数据类型的服务, 并使用支持良好格式化 XML 和 JSON 数据的插件的 Web 浏览器进行初步开发. 我会进行大量探索并记下大量笔记, 然后再编写任何代码.
16.4. 总结
我试图在本短章中提供一些示例和建议, 向你展示即使像 Ruby 和 Python 这样的其他语言拥有更多用于从网络收集信息的库和工具, Common Lisp 也拥有用于信息收集的好库, 并且它们可以通过 Quicklisp 轻松使用.
17. 使用 CL 机器学习库
CL 机器学习 (CLML) 库最初由 MSI (NTT DATA 数学系统公司, 日本) 开发, 并得到许多开发人员的支持. 你应该访问 CLML 网页51 查看项目文档, 并按照安装说明进行操作, 在使用本章中的示例之前阅读有关项目的信息. 然而, 如果你只想快速尝试以下 CLML 示例, 你可以使用 Quicklisp 安装 CLML:
mkdir -p ~/quicklisp/local-projects cd ~/quicklisp/local-projects git clone https://github.com/mmaul/clml.git sbcl --dynamic-space-size 2560 > (ql:quickload :clml :verbose t)
安装过程需要一段时间才能运行, 但安装后通过 quickload 使用库速度很快. 你现在可以运行示例 Quicklisp 项目 src/clml_examples:
$ sbcl --dynamic-space-size 2560 * (ql:quickload "clmltest") * (clmltest:clml-tests-example)
请耐心等待第一次运行, 因为第一次加载示例项目时, CLML 的一次性安装需要一段时间才能运行, 但安装后示例项目加载速度很快. CLML 安装涉及下载和安装 BLAS, LAPACK 和其他库. CLML 的其他资源包括教程52 和贡献扩展53, 其中包括对绘图 (使用多个库) 和获取数据集的支持. 尽管 CLML 相当可移植, 但我们将使用 SBCL, 并且当我们要使用 CLML 库时, 需要在启动 SBCL 时增加堆空间:
sbcl --dynamic-space-size 5000
17.1. 使用 CLML 数据加载和访问 API
你可以参考 https://github.com/mmaul/clml%5Bfn:61%5D 上的文档. 此文档列出了包以及每个包的一些信息, 但实际上我将 CLML 的源代码保存在编辑器或 IDE 中, 并在编写使用 CLML 的代码时阅读源代码. 我将通过简短的示例向你展示如何使用 KNN (K 最近邻) 和 SVM (支持向量机) API. 我们不会涵盖其他有用的 CLML API, 如时间序列处理, 朴素贝叶斯, PCA (主成分分析) 以及通用矩阵和张量操作.
尽管学习曲线有点陡峭, CLML 提供了大量用于机器学习, 处理时间序列数据以及通用矩阵和张量操作的功能.
CLML 项目使用了几个数据集, 由于我们将使用的少数几个是小文件, 它们包含在本书的仓库中, 位于 src 目录下的 machine_learning_data 目录中. labeled_cancer_training_data.csv 的前几行是:
Cl.thickness,Cell.size,Cell.shape,Marg.adhesion,Epith.c.size,Bare.nuclei,Bl.cromatin,Normal.nucleoli,Mitoses,Class 5,4,4,5,7,10,3,2,1,benign 6,8,8,1,3,4,3,7,1,benign 8,10,10,8,7,10,9,7,1,malignant 2,1,2,1,2,1,3,1,1,benign
CSV 数据文件的第一行指定了每个属性的名称, 最后一列的名称为 “Class”, 在这里取值为 benign 或 malignant. 稍后, 目标将是创建从训练数据构建的模型, 然后对新输入数据的 “Class” 进行预测. 我们稍后将研究如何构建和使用机器学习模型, 但在这里我们专注于读取和使用输入数据.
示例文件 clml_data_apis.lisp 展示了如何打开文件并遍历每一行的值:
;; note; run SBCL using: sbcl --dynamic-space-size 2560 (ql:quickload '(:clml :clml.hjs)) ; read data sets (defpackage #:clml-data-test (:use #:cl #:clml.hjs.read-data)) (in-package #:clml-data-test) (defun read-data () (let ((train1 (clml.hjs.read-data:read-data-from-file "./machine_learning_data/labeled_cancer_training_data.csv" :type :csv :csv-type-spec (append (make-list 9 :initial-element 'double-float) '(symbol))))) (loop-over-and-print-data train1))) (defun loop-over-and-print-data (clml-data-set) (print "Loop over and print a CLML data set:") (let ((testdata (clml.hjs.read-data:dataset-points clml-data-set))) (loop for td across testdata do (print td)))) (read-data)
第 11-19 行定义的函数 read-data 使用实用函数 clml.hjs.read-data:read-data-from-file 从磁盘读取 CSV (逗号分隔值) 电子表格文件. CSV 文件预期包含 10 列 (在第 17-18 行设置), 前九列包含浮点值, 最后一列包含文本数据.
第 21-26 行定义的函数 loop-over-and-print-data 读取 CLML 数据集对象, 遍历每个数据样本 (即原始电子表格文件中的每一行) 并打印它.
以下是加载此文件的一些输出:
$ sbcl --dynamic-space-size 2560 This is SBCL 1.3.16, an implementation of ANSI Common Lisp. More information about SBCL is available at <http://www.sbcl.org/>. SBCL is free software, provided as is, with absolutely no warranty. It is mostly in the public domain; some portions are provided under BSD-style licenses. See the CREDITS and COPYING files in the distribution for more information. * (load "clml_data_apis.lisp") "Loop over and print a CLML data set:" #(5.0d0 4.0d0 4.0d0 5.0d0 7.0d0 10.0d0 3.0d0 2.0d0 1.0d0 |benign|) #(6.0d0 8.0d0 8.0d0 1.0d0 3.0d0 4.0d0 3.0d0 7.0d0 1.0d0 |benign|) #(8.0d0 10.0d0 10.0d0 8.0d0 7.0d0 10.0d0 9.0d0 7.0d0 1.0d0 |malignant|) #(2.0d0 1.0d0 2.0d0 1.0d0 2.0d0 1.0d0 3.0d0 1.0d0 1.0d0 |benign|)
在下一节中, 我们将使用相同的癌症数据训练文件和另一个相同格式的测试数据来将此癌症数据聚类到相似的集合中, 一个集合用于非恶性样本, 一个集合用于恶性样本.
17.2. 癌症数据集的 K-Means 聚类
我们现在将读取相同的威斯康星大学癌症数据集, 并将输入样本 (电子表格文件中的每行一个样本) 聚类到相似的类别中. 我们会发现在训练模型后, 数据被分成两个聚类, 分别代表非恶性和恶性样本.
第 33-47 行定义的函数 cancer-data-cluster-example-read-data 与上一节中的函数 read-data 非常相似, 不同之处在于这里我们读取了两个数据文件: 一个用于训练, 一个用于测试.
第 13-30 行定义的函数 cluster-using-k-nn 使用训练和测试数据对象首先训练模型, 然后使用先前用于训练的测试数据对其进行测试. 注意我们在第 47 行如何调用此函数: 前两个参数是两个数据集对象, 第三个参数是字符串 “Class”, 它是原始电子表格 CSV 文件第 10 列的标签, 最后一个参数是用于比较两个数据样本的距离度量类型 (即比较训练 CSV 数据文件中的任意两行).
;; note; run SBCL using: sbcl --dynamic-space-size 2560 (ql:quickload '(:clml :clml.hjs ; utilities :clml.clustering)) (defpackage #:clml-knn-cluster-example1 (:use #:cl #:clml.hjs.read-data)) (in-package #:clml-knn-cluster-example1) ;; folowing is derived from test code in CLML: (defun cluster-using-k-nn (test train objective-param-name manhattan) (let (original-data-column-length) (setq original-data-column-length (length (aref (clml.hjs.read-data:dataset-points train) 0))) (let* ((k 5) (k-nn-estimator (clml.nearest-search.k-nn:k-nn-analyze train k objective-param-name :all :distance manhattan :normalize t))) (loop for data across (dataset-points (clml.nearest-search.k-nn:k-nn-estimate k-nn-estimator test)) if (equal (aref data 0) (aref data original-data-column-length)) do (format t "Correct: ~a~%" data) else do (format t "Wrong: ~a~%" data))))) ;; folowing is derived from test code in CLML: (defun cancer-data-cluster-example-read-data () (let ((train1 (clml.hjs.read-data:read-data-from-file "./machine_learning_data/labeled_cancer_training_data.csv" :type :csv :csv-type-spec (append (make-list 9 :initial-element 'double-float) '(symbol)))) (test1 (clml.hjs.read-data:read-data-from-file "./machine_learning_data/labeled_cancer_test_data.csv" :type :csv :csv-type-spec (append (make-list 9 :initial-element 'double-float) '(symbol))))) ;;(print test1) (print (cluster-using-k-nn test1 train1 "Class" :double-manhattan)))) (cancer-data-cluster-example-read-data)
以下列表显示了运行最后一个代码示例的输出:
Number of self-misjudgement : 13 Correct: #(benign 5.0d0 1.0d0 1.0d0 1.0d0 2.0d0 1.0d0 3.0d0 1.0d0 1.0d0 benign) Correct: #(benign 3.0d0 1.0d0 1.0d0 1.0d0 2.0d0 2.0d0 3.0d0 1.0d0 1.0d0 benign) Correct: #(benign 4.0d0 1.0d0 1.0d0 3.0d0 2.0d0 1.0d0 3.0d0 1.0d0 1.0d0 benign) Correct: #(benign 1.0d0 1.0d0 1.0d0 1.0d0 2.0d0 10.0d0 3.0d0 1.0d0 1.0d0 benign) Correct: #(benign 2.0d0 1.0d0 1.0d0 1.0d0 2.0d0 1.0d0 1.0d0 1.0d0 5.0d0 benign) Correct: #(benign 1.0d0 1.0d0 1.0d0 1.0d0 1.0d0 1.0d0 3.0d0 1.0d0 1.0d0 benign) Wrong: #(benign 5.0d0 3.0d0 3.0d0 3.0d0 2.0d0 3.0d0 4.0d0 4.0d0 1.0d0 malignant) Correct: #(malignant 8.0d0 7.0d0 5.0d0 10.0d0 7.0d0 9.0d0 5.0d0 5.0d0 4.0d0 malignant) Correct: #(benign 4.0d0 1.0d0 1.0d0 1.0d0 2.0d0 1.0d0 2.0d0 1.0d0 1.0d0 benign) Correct: #(malignant 10.0d0 7.0d0 7.0d0 6.0d0 4.0d0 10.0d0 4.0d0 1.0d0 2.0d0 malignant) ...
17.3. 癌症数据集的 SVM 分类
我们现在将重用相同的癌症数据集, 但使用不同的方法将数据分类为非恶性和恶性类别: 支持向量机 (SVM). SVM 是线性分类器, 这意味着当数据线性可分时它们效果最佳. 在癌症数据的情况下, 有九个维度的值 (希望) 可以预测两个输出类别 (或类别) 中的一个. 如果我们将数据的前 9 列视为定义了一个 9 维空间, 那么当一个 8 维超平面将样本分成两个输出类别 (类别) 时, SVM 将工作得很好.
为了更容易可视化, 如果我们只有两个输入列, 定义了一个二维空间, 并且一条直线可以将大多数样本分成两个输出类别, 那么数据是线性可分的, 因此 SVM 是一种很好的使用技术. SVM 算法有效地确定了定义这条一维线 (或者在癌症数据情况下, 9 维超空间) 的参数.
如果数据不是线性可分的怎么办? 那么可以使用 “反向传播神经网络” 章节中的反向传播神经网络代码或 “使用 Armed Bear Common Lisp 和 DeepLearning4j” 章节中的深度学习代码来创建模型.
SVM 非常高效, 因此通常首先尝试 SVM 是有意义的, 如果训练后的模型不够准确, 则使用神经网络, 包括深度学习.
以下 clml_svm_classifier.lisp 文件的列表显示了如何读取数据, 构建模型以及使用不同的测试数据评估模型. 在第 15 行, 我们使用函数 clml.svm.mu:svm, 它需要使用的核函数类型, 训练数据和测试数据. 仅供参考, 我们通常使用高斯核函数处理数值数据, 使用线性核函数处理自然语言处理应用程序中的文本. 这里我们使用高斯核函数.
第 40 行定义的函数 cancer-data-svm-example-read-data 与我们之前读取和处理数据的方式不同, 因为我们需要分离出正例和负例训练样本. 数据在第 42-52 行的词法作用域函数中进行拆分. 第 54-82 行的最后一个代码块只是顶层测试代码, 当加载文件 clml_svm_classifier.lisp 时会执行.
;; note; run SBCL using: sbcl --dynamic-space-size 2560 (ql:quickload '(:clml :clml.hjs ; utilities :clml.svm)) (defpackage #:clml-svm-classifier-example1 (:use #:cl #:clml.hjs.read-data)) (in-package #:clml-svm-classifier-example1) (defun svm-classifier-test (kernel train test) "train and test are lists of lists, with first elements being negative samples and the second elements being positive samples" (let ((decision-function (clml.svm.mu:svm kernel (cadr train) (car train))) (correct-positives 0) (wrong-positives 0) (correct-negatives 0) (wrong-negatives 0)) ;; type: #<CLOSURE (LAMBDA (CLML.SVM.MU::Z) :IN CLML.SVM.MU::DECISION)> (print decision-function) (princ "**** * NEGATIVE TESTS: calling decision function:") (terpri) (dolist (neg (car test)) ;; negative test examples (let ((prediction (funcall decision-function neg))) (print prediction) (if prediction (incf wrong-negatives) (incf correct-negatives)))) (princ "**** * POSITIVE TESTS: calling decision function:") (terpri) (dolist (pos (cadr test)) ;; positive test examples (let ((prediction (funcall decision-function pos))) (print prediction) (if prediction (incf correct-positives) (incf wrong-positives)))) (format t "Number of correct negatives ~a~%" correct-negatives) (format t "Number of wrong negatives ~a~%" wrong-negatives) (format t "Number of correct positives ~a~%" correct-positives) (format t "Number of wrong positives ~a~%" wrong-positives))) (defun cancer-data-svm-example-read-data () (defun split-positive-negative-cases (data) (let ((negative-cases '()) (positive-cases '())) (dolist (d data) ;;(print (list " * d=" d)) (if (equal (symbol-name (first (last d))) "benign") (setf negative-cases (cons (reverse (cdr (reverse d))) negative-cases)) (setf positive-cases (cons (reverse (cdr (reverse d))) positive-cases)))) (list negative-cases positive-cases))) (let* ((train1 (clml.hjs.read-data:read-data-from-file "./machine_learning_data/labeled_cancer_training_data.csv" :type :csv :csv-type-spec (append (make-list 9 :initial-element 'double-float) '(symbol)))) (train-as-list (split-positive-negative-cases (coerce (map 'list #'(lambda (x) (coerce x 'list)) (coerce (clml.hjs.read-data:dataset-points train1) 'list)) 'list))) (test1 (clml.hjs.read-data:read-data-from-file "./machine_learning_data/labeled_cancer_test_data.csv" :type :csv :csv-type-spec (append (make-list 9 :initial-element 'double-float) '(symbol)))) (test-as-list (split-positive-negative-cases (coerce (map 'list #'(lambda (x) (coerce x 'list)) (coerce (clml.hjs.read-data:dataset-points test1) 'list)) 'list)))) ;; we will use a gaussian kernel for numeric data. ;; note: for text classification, use a clml.svm.mu:+linear-kernel+ (svm-classifier-test (clml.svm.mu:gaussian-kernel 2.0d0) train-as-list test-as-list))) (cancer-data-svm-example-read-data)
示例代码打印测试数据的预测值, 我在这里不再显示. 以下是显示测试数据累积统计信息的最后四行输出:
Number of correct negatives 219 Number of wrong negatives 4 Number of correct positives 116 Number of wrong positives 6
17.4. CLML 总结
CLML 机器学习库正处于相当活跃的开发中, 我向你展示了足够多的入门知识: 理解数据 API 以及 KNN 聚类和 SVM 分类的示例. CLML 的一个很好的替代品是 MGL54, 它支持反向传播神经网络, 玻尔兹曼机和高斯过程. 在接下来的两章中, 我们将继续讨论机器学习主题, 涉及反向传播和 Hopfield 神经网络.
18. 反向传播神经网络
让我们从概述这些网络的工作原理开始, 然后再深入细节. 反向传播网络通过将训练输入应用于网络输入层, 将值通过网络传播到输出神经元, 比较这些传播的输出值与训练数据输出值之间的误差 (或差异) 来进行训练. 这些输出误差会反向传播到网络中, 反向传播误差的大小用于调整网络中的权重.
我们在这里看的例子使用了前面章节的 plotlib 包, 例子的源代码是文件 loving_snippet/backprop_neural_network.lisp.
我们将使用下图来使这个过程更清晰. 这个非常简单的网络中有四个权重:
- \(W^{1,1}\) 是表示 inputneuron¹ 和 outputneuron¹ 之间连接强度的浮点数
- \(W^{2,1}\) 连接 inputneuron² 和 outputneuron¹
- \(W^{1,2}\) 连接 inputneuron¹ 和 outputneuron²
- \(W^{2,2}\) 连接 inputneuron² 和 outputneuron²
在任何训练之前, 权重值都是小的随机数. 考虑一个训练数据元素, 其中输入神经元的值为 [0.1, 0.9], 期望的输出神经元值为 [0.9, 0.1], 即翻转输入值. 如果当前权重的传播输出值为 [0.85, 0.5], 那么第一个输出神经元的值误差很小 abs(0.85 - 0.9), 即 0.05. 然而, 第二个输出神经元的传播误差很高: abs(0.5 - 0.1), 即 0.4. 非正式地, 我们看到馈送输入输出神经元 1 的权重 (\(W^{1,1}\) 和 \(W^{2,1}\)) 不需要太大改变, 但馈送输入神经元 2 的神经元 (\(W^{1,2}\) 和 \(W^{2,2}\)) 需要修改 (\(W^{2,2}\) 的值太大了). 当然, 我们绝不会尝试手动训练这样的网络, 但至少对权重如何连接值流 (我们稍后称之为激活值) 在神经元之间有一个非正式的理解是很重要的. 在这个神经网络中, 我们在第一个图中看到有四个权重连接输入和输出神经元. 可以将这四个权重看作构成一个四维空间, 其中每个维度的范围被限制在小的正负浮点值内. 在这个 “权重空间” 的任何一点, 权重的数值定义了一个将输入映射到输出的模型. 输出神经元处的误差是针对每个训练样本 (应用于输入神经元) 累积的. 训练过程是在这个四维空间中找到一个点, 使得在所有训练数据上累积的误差都很低. 我们将使用梯度下降法, 从四维空间中的一个随机点 (即一组初始随机权重) 开始, 并将该点移向表示 (希望) 在表示训练数据方面 “足够好” 的模型的权重的局部最小值. 这个过程足够简单, 但有一些实际考虑:
- 有时, 即使经过许多训练周期, 局部最小值的累积误差也太大, 最好用新的随机权重重新开始训练过程.
- 如果我们没有足够的训练数据, 那么网络可能具有足够的内存容量来记住训练样本. 这不是我们想要的: 我们想要一个具有刚好足够内存容量 (由权重数量表示) 的模型来形成一个通用的预测模型, 但又不能太具体以至于它只记住了训练样本. 解决方案是使用小型网络 (少量隐藏神经元) 开始, 并增加神经元的数量, 直到可以学习训练数据. 总的来说, 拥有大量训练数据是好的, 使用尽可能小的网络也是好的.
在实践中, 使用反向传播网络是一个迭代的过程, 需要试验网络的大小.
在示例程序 (backprop_neural_network.lisp 文件) 中, 我们使用之前开发的绘图库来可视化网络训练时的神经元激活和连接权重值.
以下三个来自运行文件 backprop_neural_network.lisp 底部定义的函数 test3 的屏幕截图说明了从随机权重开始, 在初始训练期间获得随机输出, 以及随着 delta 权重用于调整网络中的权重, 然后学习训练样本的过程:
在最后一个图中, 初始权重是随机的, 所以我们在输出神经元得到随机的中间范围值.
当我们开始训练网络, 调整权重时, 我们开始看到输出神经元的变化是输入内容的函数.
在最后一个图中, 网络训练得足够好, 可以将输入 [0, 0, 0, 1] 映射到大约 [0.8, 0.2, 0.2, 0.3] 的输出值, 这接近期望值 [1, 0, 0, 0].
示例源文件 backprop_neural_network.lisp 很长, 所以我们只看其中更有趣的部分. 具体来说, 我们不会看使用 plotlib 绘制神经网络的代码.
单个神经元的激活值被限制在 [0, 1] 范围内, 首先根据前一层神经元的总激活值乘以连接权重的值计算它们的值, 然后使用 Sigmoid 函数将总和映射到所需范围. Sigmoid 函数及其导数 (dSigmoid) 如下所示:
以下是这些函数的定义:
(defun Sigmoid (x) (/ 1.0 (+ 1.0 (exp (- x))))) (defun dSigmoid (x) (let ((temp (Sigmoid x))) ( * temp (- 1.0 temp)))
函数 NewDeltaNetwork 创建一个新的神经网络对象. 此代码为输入, 隐藏, 输出层 (我有时将神经元层称为 “slabs”) 以及连接权重分配存储空间. 连接权重被初始化为小的随机值.
; (NewDeltaNetwork sizeList) ; Args: sizeList = list of sizes of slabs. This also defines ; the number of slabs in the network. ; (e.g., '(10 5 4) ==> a 3-slab network with 10 ; input neurons, 5 hidden neurons, and 4 output ; neurons). ; ; Returned value = a list describing the network: ; (nLayers sizeList ; (activation-array[1] .. activation-array[nLayers]) ; (weight-array[2] .. weight-array[nLayers]) ; (sum-of-products[2] .. sum-of-products[nLayers[nLayers]) ; (back-prop-error[2] .. back-prop-error[nLayers])) ; (old-delta-weights[2] .. for momentum term :initial-element 0.0)) (reverse old-dw-list))) ;; ; Initialize values for all activations: ;; (mapc (lambda (x) (let ((num (array-dimension x 0))) (dotimes (n num) (setf (aref x n) (frandom 0.01 0.1))))) a-list) ;; ; Initialize values for all weights: ;; (mapc (lambda (x) (let ((numI (array-dimension x 0)) (numJ (array-dimension x 1))) (dotimes (j numJ) (dotimes (i numI) (setf (aref x i j) (frandom -0.5 0.5)))))) w-list) (list numLayers sizeList a-list s-list w-list dw-list d-list old-dw-list alpha beta)))
在以下列表中, 函数 DeltaLearn 处理所有训练数据的一次传递. 函数 DeltaLearn 被重复调用, 直到返回值低于期望的误差阈值. 对每个训练样本的主循环在第 69-187 行实现. 在这个外层循环内部, 每个训练样本有两个训练阶段: 一个前向传播阶段, 将激活从输入神经元通过任何隐藏层传播到输出神经元 (第 87-143 行), 然后是权重校正反向传播输出误差, 同时对权重进行小的调整 (第 148-187 行):
;; ; Utility function for training a delta rule neural network. ; The first argument is the name of an output PNG plot file ; and a nil value turns off plotting the network during training. ; The second argument is a network definition (as returned from ; NewDeltaNetwork), the third argument is a list of training ; data cases (see the example test functions at the end of this ; file for examples. ;; (defun DeltaLearn (plot-output-file-name netList trainList) (let ((nLayers (car netList)) (sizeList (cadr netList)) (activationList (caddr netList)) (sumOfProductsList (car (cdddr netList))) (weightList (cadr (cdddr netList))) (deltaWeightList (caddr (cdddr netList))) (deltaList (cadddr (cdddr netList))) (oldDeltaWeightList (cadddr (cdddr (cdr netList)))) (alpha (cadddr (cdddr (cddr netList)))) (beta (cadddr (cdddr (cdddr netList)))) (inputs nil) (targetOutputs nil) (iDimension nil) (jDimension nil) (iActivationVector nil) (jActivationVector nil) (n nil) (weightArray nil) (sumOfProductsArray nil) (iDeltaVector nil) (jDeltaVector nil) (deltaWeightArray nil) (oldDeltaWeightArray nil) (sum nil) (iSumOfProductsArray nil) (error nil) (outputError 0) (delta nil) (eida nil) (inputNoise 0)) ;; ; Zero out deltas: ;; (dotimes (n (- nLayers 1)) (let* ((dw (nth n deltaList)) (len1 (array-dimension dw 0))) (dotimes (i len1) (setf (aref dw i) 0.0)))) ;; ; Zero out delta weights: ;; (dotimes (n (- nLayers 1)) (let* ((dw (nth n deltaWeightList)) (len1 (array-dimension dw 0)) (len2 (array-dimension dw 1))) (dotimes (i len1) (dotimes (j len2) (setf (aref dw i j) 0.0))))) (setq inputNoise *delta-default-input-noise-value*) ;; ; Main loop on training examples: ;; (dolist (tl trainList) (setq inputs (car tl)) (setq targetOutputs (cadr tl)) (if *delta-rule-debug-flag* (print (list "Current targets:" targetOutputs))) (setq iDimension (car sizeList)) ; get the size of the input slab (setq iActivationVector (car activationList)) ; input activations (dotimes (i iDimension) ; copy training inputs to input slab (setf (aref iActivationVector i) (+ (nth i inputs) (frandom (- inputNoise) inputNoise)))) ;; ; Propagate activation through all of the slabs: ;; (dotimes (n-1 (- nLayers 1)) ; update layer i to layer flowing to layer j (setq n (+ n-1 1)) (setq jDimension (nth n sizeList)) ; get the size of the j'th layer (setq jActivationVector (nth n activationList)) ; activation for slab j (setq weightArray (nth n-1 weightList)) (setq sumOfProductsArray (nth n-1 sumOfProductsList)) (dotimes (j jDimension) ; process each neuron in slab j (setq sum 0.0) ; init sum of products to zero (dotimes (i iDimension) ; activation from neurons in previous slab (setq sum (+ sum ( * (aref weightArray i j) (aref iActivationVector i))))) (setf (aref sumOfProductsArray j) sum) ; save sum of products (setf (aref jActivationVector j) (Sigmoid sum))) (setq iDimension jDimension) ; reset index for next slab pair (setq iActivationVector jActivationVector)) ;; ; Activation is spread through the network and sum of products ; calculated. Now modify the weights in the network using back ; error propagation. Start by calculating the error signal for ; each neuron in the output layer: ;; (setq jDimension (nth (- nLayers 1) sizeList)) ; size of last layer (setq jActivationVector (nth (- nLayers 1) activationList)) (setq jDeltaVector (nth (- nLayers 2) deltaList)) (setq sumOfProductsArray (nth (- nLayers 2) sumOfProductsList)) (setq outputError 0) (dotimes (j jDimension) (setq delta (- (nth j targetOutputs) (aref jActivationVector j))) (setq outputError (+ outputError (abs delta))) (setf (aref jDeltaVector j) (+ (aref jDeltaVector j) ( * delta (dSigmoid (aref sumOfProductsArray j)))))) ;; ; Now calculate the backpropagated error signal for all hidden slabs: ;; (dotimes (nn (- nLayers 2)) (setq n (- nLayers 3 nn)) (setq iDimension (nth (+ n 1) sizeList)) (setq iSumOfProductsArray (nth n sumOfProductsList)) (setq iDeltaVector (nth n deltaList)) (dotimes (i iDimension) (setf (aref iDeltaVector i) 0.0)) (setq weightArray (nth (+ n 1) weightList)) (dotimes (i iDimension) (setq error 0.0) (dotimes (j jDimension) (setq error (+ error ( * (aref jDeltaVector j) (aref weightArray i j))))) (setf (aref iDeltaVector i) (+ (aref iDeltaVector i) ( * error (dSigmoid (aref iSumOfProductsArray i)))))) (setq jDimension iDimension) (setq jDeltaVector iDeltaVector)) ;; ; Update all delta weights in the network: ;; (setq iDimension (car sizeList)) (dotimes (n (- nLayers 1)) (setq iActivationVector (nth n activationList)) (setq jDimension (nth (+ n 1) sizeList)) (setq jDeltaVector (nth n deltaList)) (setq deltaWeightArray (nth n deltaWeightList)) (setq weightArray (nth n weightList)) (setq eida (nth n eidaList)) (dotimes (j jDimension) (dotimes (i iDimension) (setq delta ( * eida (aref jDeltaVector j) (aref iActivationVector i))) (setf (aref DeltaWeightArray i j) (+ (aref DeltaWeightArray i j) delta)))) ; delta weight changes (setq iDimension jDimension)) ;; ; Update all weights in the network: ;; (setq iDimension (car sizeList)) (dotimes (n (- nLayers 1)) (setq iActivationVector (nth n activationList)) (setq jDimension (nth (+ n 1) sizeList)) (setq jDeltaVector (nth n deltaList)) (setq deltaWeightArray (nth n deltaWeightList)) (setq oldDeltaWeightArray (nth n oldDeltaWeightList)) (setq weightArray (nth n weightList)) (dotimes (j jDimension) (dotimes (i iDimension) (setf (aref weightArray i j) (+ (aref weightArray i j) ( * alpha (aref deltaWeightArray i j)) ( * beta (aref oldDeltaWeightArray i j)))) (setf (aref oldDeltaWeightArray i j) ; save current delta weights (aref deltaWeightArray i j)))) ; ...for next momentum term. (setq iDimension jDimension)) (if plot-output-file-name (DeltaPlot netList plot-output-file-name))) (/ outputError jDimension)))
下一个列表中的函数 DeltaRecall 可以与训练好的网络一起使用, 以计算新输入值的输出:
;; ; Utility for using a trained neural network in the recall mode. ; The first argument to this function is a network definition (as ; returned from NewDeltaNetwork) and the second argument is a list ; of input neuron activation values to drive through the network. ; The output is a list of the calculated activation energy for ; each output neuron. ;; (defun DeltaRecall (netList inputs) (let ((nLayers (car netList)) (sizeList (cadr netList)) (activationList (caddr netList)) (weightList (cadr (cdddr netList))) (iDimension nil) (jDimension nil) (iActivationVector nil) (jActivationVector nil) (n nil) (weightArray nil) (returnList nil) (sum nil)) (setq iDimension (car sizeList)) ; get the size of the input slab (setq iActivationVector (car activationList)) ; get input activations (dotimes (i iDimension) ; copy training inputs to input slab (setf (aref iActivationVector i) (nth i inputs))) (dotimes (n-1 (- nLayers 1)) ; update layer j to layer i (setq n (+ n-1 1)) (setq jDimension (nth n sizeList)) ; get the size of the j'th layer (setq jActivationVector (nth n activationList)) ; activation for slab j (setq weightArray (nth n-1 weightList)) (dotimes (j jDimension) ; process each neuron in slab j (setq sum 0.0) ; init sum of products to zero (dotimes (i iDimension) ; get activation from each neuron in last slab (setq sum (+ sum ( * (aref weightArray i j) (aref iActivationVector i))))) (if *delta-rule-debug-flag* (print (list "sum=" sum))) (setf (aref jActivationVector j) (Sigmoid sum))) (setq iDimension jDimension) ; get ready for next slab pair (setq iActivationVector jActivationVector)) (dotimes (j jDimension) (setq returnList (append returnList (list (aref jActivationVector j))))) returnList))
我们之前看到了三个输出图, 它们是在使用以下代码的训练运行期间产生的:
(defun test3 (&optional (restart 'yes) &aux RMSerror) ; three layer network (if (equal restart 'yes) (setq temp (newdeltanetwork '(5 4 5)))) (dotimes (ii 3000) (let ((file-name (if (equal (mod ii 400) 0) (concatenate 'string "output_plot_" (format nil "~12,'0d" ii) ".png") nil))) (setq RMSerror (deltalearn file-name temp '(((1 0 0 0 0) (0 1 0 0 0)) ((0 1 0 0 0) (0 0 1 0 0)) ((0 0 1 0 0) (0 0 0 1 0)) ((0 0 0 1 0) (0 0 0 0 1)) ((0 0 0 0 1) (1 0 0 0 0))))) (if (equal (mod ii 50) 0) ;; print error out every 50 cycles (progn (princ "....training cycle \#") (princ ii) (princ " RMS error = ") (princ RMSerror) (terpri))))))
这里的函数 test3 为一个非常小的测试网络定义了训练数据, 用于一个适度困难的学习函数: 将输入神经元中的值向右旋转, 环绕到第一个神经元. 第 行主循环的开始调用训练函数 3000 次, 每 400 次通过主循环创建一个网络图.
反向传播网络已经在生产中成功使用了大约 25 年. 在下一章中, 我们将看一种不太实用的网络类型, Hopfield 网络, 但仍然很有趣, 因为在某种意义上 Hopfield 网络模拟了我们大脑的工作方式. 在最后一章中, 我们将探讨深度学习神经网络.
- Hopfield 神经网络
Hopfield 网络55 (以 John Hopfield 命名) 是一种循环网络, 因为激活流在网络中存在循环. 这些网络通过应用输入模式进行训练, 让网络稳定在一个存储输入模式的状态.
示例代码位于文件 src/loving_snippets/Hopfield_neural_network.lisp 中.
我们看的这个例子识别与训练样本中看到的模式相似的模式, 并将输入模式映射到相似的训练输入模式. 下图显示了示例程序的输出, 显示了一个原始训练模式, 一个单元格打开而另一个关闭的相似模式, 以及重建的模式:
需要明确的是, 我们取了网络学习到的原始输入模式之一, 对其稍作修改, 并将其作为输入应用于网络. 在网络循环之后, 我们刚刚应用的轻微打乱的输入模式将用作联想记忆密钥, 查找原始模式, 并用原始学习到的模式重写输入值. 这些 Hopfield 网络与反向传播网络非常不同: 神经元激活被强制为 -1 或 +1 的值, 并且不可微, 并且没有单独的输出神经元. 下一个例子修改了原始模式的三个单元格的值, 原始模式仍然被正确重建:
这个最后的例子修改了原始单元格的四个:
以下示例程序展示了一种内容可寻址内存. 在 Hopfield 网络学习了一组输入模式后, 当显示相似模式时, 它可以重建原始模式. 这种重建并非总是完美的.
以下函数 Hopfield-Init (在文件 Hopfield_neural_network.lisp 中) 传递一个训练样本列表的列表, 这些样本将在网络中被记住. 此函数返回一个包含定义 Hopfield 神经网络的数据的列表. 网络的所有数据都封装在此函数返回的列表中, 因此可以在应用程序中使用多个 Hopfield 神经网络.
在第 9-12 行, 我们分配用于数据存储的全局数组, 在第 14-18 行复制训练数据.
内部函数 adjustInput 在第 20-29 行将数据值调整为 -1.0 或 +1.0. 在第 31-33 行, 我们将 Hopfield 网络中的所有权重初始化为零.
最后一个嵌套循环, 在第 35-52 行, 根据输入测试模式计算自相关权重矩阵.
在第 54-56 行, 该函数返回 Hopfield 网络的表示, 该表示稍后将在函数 HopfieldNetRecall 中使用, 以便在给定新的 (新鲜的) 输入模式时找到最相似的 “记住的” 模式.
(defun Hopfield-Init (training-data &aux temp *num-inputs * *num-training-examples* *training-list * *inputCells* *tempStorage* *HopfieldWeights*) (setq *num-inputs * (length (car training-data))) (setq *num-training-examples * (length training-data)) (setq *training-list * (make-array (list *num-training-examples * *num-inputs*))) (setq *inputCells * (make-array (list *num-inputs*))) (setq *tempStorage * (make-array (list *num-inputs*))) (setq *HopfieldWeights * (make-array (list *num-inputs * *num-inputs*))) (dotimes (j *num-training-examples*) ;; copy training data (dotimes (i *num-inputs*) (setf (aref *training-list * j i) (nth i (nth j training-data))))) (defun adjustInput (value) ;; this function is lexically scoped (if (< value 0.1) -1.0 +1.0)) (dotimes (i *num-inputs*) ;; adjust training data (dotimes (n *num-training-examples*) (setf (aref *training-list * n i) (adjustInput (aref *training-list * n i))))) (dotimes (i *num-inputs*) ;; zero weights (dotimes (j *num-inputs*) (setf (aref *HopfieldWeights * i j) 0))) (dotimes (j-1 (- *num-inputs * 1)) ;; autocorrelation weight matrix (let ((j (+ j-1 1))) (dotimes (i j) (dotimes (s *num-training-examples*) (setq temp (truncate (+ (* ;; 2 if's truncate values to -1 or 1: (adjustInput (aref *training-list * s i)) (adjustInput (aref *training-list * s j))) (aref *HopfieldWeights * i j)))) (setf (aref *HopfieldWeights * i j) temp) (setf (aref *HopfieldWeights * j i) temp))))) (dotimes (i *num-inputs*) (setf (aref *tempStorage * i) 0) (dotimes (j i) (setf (aref *tempStorage * i) (+ (aref *tempStorage * i) (aref *HopfieldWeights* i j))))) (list ;; return the value of the Hopfield network data object *num-inputs * *num-training-examples * *training-list* *inputCells * *tempStorage* *HopfieldWeights*))
以下函数 HopfieldNetRecall 迭代网络以使其稳定在一个稳定模式, 我们希望该模式是与噪声测试模式最相似的原始训练模式.
内部 (词法作用域) 函数 deltaEnergy 定义在第 9-12 行, 计算来自旧输入值和自相关权重矩阵的能量变化. 主代码使用内部函数迭代输入单元格, 如果索引 i 处的单元格能量变化大于零, 则可能修改该单元格. 请记住, 词法作用域的内部函数可以访问输入数量, 训练样本数量, 训练样本列表, 输入单元格值, 临时存储以及 Hopfield 网络权重的变量.
(defun HopfieldNetRecall (aHopfieldNetwork numberOfIterations) (let ((*num-inputs * (nth 0 aHopfieldNetwork)) (*num-training-examples * (nth 1 aHopfieldNetwork)) (*training-list * (nth 2 aHopfieldNetwork)) (*inputCells * (nth 3 aHopfieldNetwork)) (*tempStorage * (nth 4 aHopfieldNetwork)) (*HopfieldWeights * (nth 5 aHopfieldNetwork))) (defun deltaEnergy (row-index y &aux (temp 0.0)) ;; lexically scoped (dotimes (j *num-inputs*) (setq temp (+ temp ( * (aref *HopfieldWeights* row-index j) (aref y j))))) (- ( * 2.0 temp) (aref *tempStorage * row-index))) (dotimes (ii numberOfIterations) ;; main code (dotimes (i *num-inputs*) (setf (aref *inputCells * i) (if (> (deltaEnergy i *inputCells*) 0) 1 0))))))
下一个列表中的函数 test 对每个测试使用三个不同的模式. 注意, 只有最后一个模式被绘制到输出图形 PNG 文件中, 目的是为本章生成图形. 如果你想绘制其他模式的图, 只需编辑第 AAAAA 行定义的第三个模式. 以下绘图函数是内部词法作用域的, 因此它们可以访问在第 16-21 行的封闭 let 表达式中定义的数据:
plotExemplar- 绘制数据向量plot-original-inputCells- 绘制来自训练数据的原始输入单元格plot-inputCells- 绘制修改后的输入单元格 (一些单元格的值被随机翻转)modifyInput- 打乱训练输入
(defun test (&aux aHopfieldNetwork) (let ((tdata '( ;; sample sine wave data with different periods: (1 0 0 0 1 0 0 0 1 0 0 0 1 0 0 0 1 0 0 0 0 0 1 1 0 0 0) (0 1 1 0 0 0 0 0 1 0 0 1 0 0 0 0 0 1 0 0 0 1 1 0 0 1 0) (0 0 0 1 1 0 0 0 0 0 0 1 1 0 0 0 0 0 1 1 0 0 1 1 0 1 1))) (width 300) (height 180)) (vecto::with-canvas (:width width :height height) (plotlib:plot-string-bold 10 (- height 14) "Hopfield pattern classifier") ;; Set up network: (print tdata) (setq aHopfieldNetwork (Hopfield-Init tdata)) ;; lexically scoped variables are accesible by inner functions: (let ((*num-inputs * (nth 0 aHopfieldNetwork)) (*num-training-examples * (nth 1 aHopfieldNetwork)) (*training-list * (nth 2 aHopfieldNetwork)) (*inputCells * (nth 3 aHopfieldNetwork)) (*tempStorage * (nth 4 aHopfieldNetwork)) (*HopfieldWeights * (nth 5 aHopfieldNetwork))) (defun plotExemplar (row &aux (dmin 0.0) (dmax 1.0) (x 20) (y 40)) (let ((YSize (array-dimension *training-list * 1))) (plotlib:plot-string (+ x 20) (- height (- y 10)) "Original Training Exemplar") (dotimes (j Ysize) (plotlib:plot-fill-rect (+ x ( * j plot-size+1)) (- height y) plot-size plot-size (truncate (* (/ (- (aref *training-list * row j) dmin) (- dmax dmin)) 5))) (plotlib:plot-frame-rect (+ x ( * j plot-size+1)) (- height y) plot-size plot-size)))) (defun plot-original-inputCells (&aux (dmin 0.0) (dmax 1.0) (x 20) (y 80)) (let ((Xsize (array-dimension *inputCells * 0))) (plotlib:plot-string (+ x 20) (- height (- y 10)) "Scrambled Inputs") (dotimes (j Xsize) (plotlib:plot-fill-rect (+ x ( * j plot-size+1)) (- height y) plot-size plot-size (truncate (* (/ (- (aref *inputCells * j) dmin) (- dmax dmin)) 5))) (plotlib:plot-frame-rect (+ x ( * j plot-size+1)) (- height y) plot-size plot-size)))) (defun plot-inputCells (&aux (dmin 0.0) (dmax 1.0) (x 20) (y 120)) (let ((Xsize (array-dimension *inputCells * 0))) (plotlib:plot-string (+ x 20) (- height (- y 10)) "Reconstructed Inputs") (dotimes (j Xsize) (plotlib:plot-fill-rect (+ x ( * j plot-size+1)) (- height y) plot-size plot-size (truncate ( * (/ (- (aref *inputCells * j) dmin) (- dmax dmin)) 5))) (plotlib:plot-frame-rect (+ x ( * j plot-size+1)) (- height y) plot-size plot-size)))) (defun modifyInput (arrSize arr) ;; modify input array for testing (dotimes (i arrSize) (if (< (random 50) 5) (if (> (aref arr i) 0) (setf (aref arr i) -1) (setf (aref arr i) 1))))) ;; Test network on training data that is randomly modified: (dotimes (iter 10) ;; cycle 10 times and make 10 plots (dotimes (s *num-training-examples*) (dotimes (i *num-inputs*) (setf (aref *inputCells * i) (aref *training-list * s i))) (plotExemplar s) (modifyInput *num-inputs * *inputCells*) (plot-original-inputCells) (dotimes (call-net 5) ;; iterate Hopfield net 5 times (HopfieldNetRecall aHopfieldNetwork 1) ;; calling with 1 iteration (plot-inputCells))) (vecto::save-png (concatenate 'string "output_plot_hopfield_nn_" (format nil "~5,'0d" iter) ".png")))))))
第 23-62 行的绘图函数使用 plotlib 库来制作你之前看到的图. 第 64-69 行的函数 modifyInput 随机翻转输入单元格的值, 取一个原始模式并对其稍作修改.
Hopfield 神经网络, 至少在某种程度上, 似乎模拟了人脑的某些方面, 因为它们可以作为内容可寻址 (也称为联想) 记忆运作. 理想情况下, 来自被记住的输入的部分输入模式可以重建完整的原始模式. Hopfield 网络的另一个有趣特征是这些记忆确实以分布式方式存储: 一些权重可以被随机改变, 模式仍然被记住, 但回忆错误更多.
19. 使用带有 Web 服务接口的 Python 深度学习模型在 Common Lisp 中
在本书的旧版本中, 我有一个使用 Java DeepLearning4J 深度学习库的例子, 使用在 Java 中实现的 Armed Bear Common Lisp. 我不再在自己的工作中使用混合 Java 和 Common Lisp 应用程序, 我决定删除这个例子, 并用两个使用简单 Python Web 服务作为最先进深度学习模型的包装器的项目来替换它, 这些模型带有位于以下子目录中的 Common Lisp 客户端:
src/spacy_web_client: 使用 spaCy 深度学习模型进行通用 NLP. 我有时使用我们在前面章节看到的我自己的纯 Common Lisp NLP 库, 有时我使用调用像 spaCy 和 TensorFlow 这样的深度学习库的 Common Lisp 客户端.src/coref_web_client: 共指或照应解析是指将文本中的代词替换为其所指的原始名词的行为. 这在传统上一直是一个非常困难且仅部分解决的问题, 直到最近像 BERT 这样的深度学习模型的进展.
注意: 在下一章中, 我们将介绍类似的功能, 但我们将使用 py4cl 库更直接地使用 Python 和像 spaCy 这样的库, 方法是启动另一个 Python 进程并使用流进行通信.
19.1. 设置本章使用的 Python Web 服务
你的系统上需要安装 python 和 pip. Python Web 服务的源代码位于目录 loving-common-lisp/python 中.
19.2. 安装 spaCY NLP 服务
我假设你对使用 Python 有一定的熟悉程度. 如果没有, 只要你的系统上安装了 pip 和 python 实用程序, 你仍然能够按照这些说明进行操作. 我建议使用 Anaconda56 安装 Python 和 Pip.
19.3. 安装共指 NLP 服务
我建议在使用 Python 应用程序时使用虚拟 Python 环境, 以隔离每个应用程序或开发项目所需的依赖项. 在这里, 我假设你正在运行 Python 3.6 环境. 首先你应该安装依赖项:
pip install spacy==2.1.0
pip install neuralcoref
pip install falcon
在我写这一章的时候, neuralcoref 模型和库需要一个稍微旧版本的 SpaCy (当前最新版本是 2.1.4).
然后切换到本书 git 仓库中的子目录 python/python_coreference_anaphora_resolution_server 并安装 coref 服务器:
cd python_coreference_anaphora_resolution_server
python setup.py install
安装服务器后, 你可以从笔记本电脑或服务器上的任何目录运行它, 使用:
corefserver
正如我们在上一个例子中看到的, 可以在 Haskell 和 Common Lisp 中直接嵌入模型, 但我发现将我使用的深度学习模型包装成 REST 服务更容易且对开发人员更友好, 正如我在这里所做的那样. 深度学习模型通常只需要大约一 GB 的内存, 使用预训练模型对 CPU 资源需求很轻, 所以当我在笔记本电脑上开发时, 我可能会运行两三个模型, 并以包装的 REST 服务形式提供. 对于生产环境, 我配置 Python 服务以及我的 Haskell 和 Common Lisp 应用程序在系统启动时自动启动. 这不是一本 Python 编程书籍, 我不会讨论简单的 Python 包装代码, 但如果你也是 Python 开发人员, 你可以轻松阅读和理解代码.
19.4. 用于 spaCy NLP Web 服务的 Common Lisp 客户端
在看代码之前, 我将向你展示运行此示例的典型输出:
$ sbcl This is SBCL 1.3.16, an implementation of ANSI Common Lisp. * (ql:quickload "spacy-web-client") To load "spacy": Load 1 ASDF system: spacy-web-client ; Loading "spacy-web-client" ......... ("spacy-web-client") * (defvar x (spacy-web-client:spacy-client "President Bill Clinton went to Congress. He gave a speech on taxes and Mexico.")) * (spacy-web-client:spacy-data-entities x) "Bill Clinton/PERSON" * (spacy-web-client:spacy-data-tokens x) ("President" "Bill" "Clinton" "went" "to" "Congress" "." "He" "gave" "a" "speech" "on" "taxes" "and" "Mexico" ".")
客户端库实现在文件 src/spacy_web_client/spacy-web-client.lisp 中:
(in-package spacy-web-client) (defvar base-url "http://127.0.0.1:8008?text=") (defstruct spacy-data entities tokens) (defun spacy-client (query) (let* ((the-bytes (drakma:http-request (concatenate 'string base-url (do-urlencode:urlencode query)) :content-type "application/text")) (fetched-data (flexi-streams:octets-to-string the-bytes :external-format :utf-8)) (lists (with-input-from-string (s fetched-data) (json:decode-json s)))) (print lists) (make-spacy-data :entities (cadar lists) :tokens (cdadr lists))))
在第 3 行, 我们定义了访问 spaCy Web 服务的基本 URL, 假设它在你的笔记本电脑上运行而不是远程服务器. 在第 5 行, 我们定义了一个名为 spacy-data 的 defstruct, 它有两个字段: 输入文本中的实体列表和输入文本中的单词标记列表.
函数 spacy-client 在第 10-12 行构建一个查询字符串, 该字符串由 base-url 和 URL 编码的输入查询文本组成. 使用我们之前使用过的 drakma 库向 Python spaCy 服务器发出 HTTP 请求. 第 14-15 行使用 flexi-streams 包将原始字节数据转换为 UTF8 字符. 第 16-17 行使用 json 包解析 UTF8 编码的字符串, 得到两个字符串列表. 我在第 18 行留下了调试打印输出表达式, 以便你可以看到解析 JSON 数据的结果. 函数 make-spacy-data 是由第 5 行的 defstruct 语句为我们生成的.
19.5. 用于共指 NLP Web 服务的 Common Lisp 客户端
让我们看一下这个例子的典型输出, 然后我们将看代码:
$ sbcl This is SBCL 1.3.16, an implementation of ANSI Common Lisp. More information about SBCL is available at <http://www.sbcl.org/>. SBCL is free software, provided as is, with absolutely no warranty. It is mostly in the public domain; some portions are provided under BSD-style licenses. See the CREDITS and COPYING files in the distribution for more information. #P"/Users/markw/quicklisp/setup.lisp" "starting up quicklisp" * (ql:quickload "coref") To load "coref": Load 1 ASDF system: coref ; Loading "coref" .................................................. [package coref] ("coref") * (coref:coref-client "My sister has a dog Henry. She loves him.") "My sister has a dog Henry. My sister loves a dog Henry." * (coref:coref-client "My sister has a dog Henry. He often runs to her.") "My sister has a dog Henry. a dog Henry often runs to My sister."
注意输入文本中的代词被正确地替换为它们所指的名词短语.
核心客户端的实现在文件 src/coref_web_client/coref.lisp 中:
(in-package #:coref) ;; (ql:quickload :do-urlencode) (defvar base-url "http://127.0.0.1:8000?text=") (defun coref-client (query) (let ((the-bytes (drakma:http-request (concatenate 'string base-url (do-urlencode:urlencode query) "&no_detail=1") :content-type "application/text"))) (flexi-streams:octets-to-string the-bytes :external-format :utf-8)))
这段代码与上一节设置 http-request 调用的示例类似, 但更简单: 在这里, Python 共指 Web 服务接受一个字符串作为输入, 并返回一个字符串作为输出, 其中代词被它们所指的名词或名词短语替换. 上一节的示例必须解析返回的 JSON 数据, 这个示例则不需要.
19.6. 故障排除可能出现的问题 - 如果此示例在你的系统上有效, 请跳过
如果你在 IDE (例如 LispWorks 的 IDE 或带有 Common Lisp 插件的 VSCode) 中运行 Common Lisp, 请确保从命令行启动 IDE, 以便你的 PATH 环境变量设置与在 bash 或 zsh shell 中一样.
确保你启动 Common Lisp 程序或运行 Common Lisp repl 时使用的是相同的 Python 安装 (如果你安装了 Quicklisp, 那么你也安装了 uiop 包):
$ which python /Users/markw/bin/anaconda3/bin/python $ sbcl This is SBCL 2.0.2, an implementation of ANSI Common Lisp. * (uiop:run-program "which python" :output :string) "/Users/markw/bin/anaconda3/bin/python" nil 0 *
19.7. Python 互操作总结
在过去的五年中, 我的大部分专业工作都涉及深度学习模型, 目前大多数可用的软件都是用 Python 编写的. 虽然有可用于从 Common Lisp 调用 Python 代码的库, 但这些库往往不适用于使用像 TensorFlow, spaCy, PyTorch 等库的 Python 代码, 特别是如果 Python 代码配置为通过 CUDA 使用 GPU 或像 TPU 这样的特殊硬件. 我发现将 Python 中实现的功能简单地包装成一个简单的 Web 服务更简单.
20. 使用 PY4CL 库在 Common Lisp 中嵌入 Python
我们将解决与上一章相同的问题, 但采用不同的方法. 现在我们将使用 Ben Dudson 的项目 Py4CL57, 它会自动启动一个 Python 进程, 并通过流接口与 Python 进程通信. 我们之前采用的方法适用于大型系统, 你可能希望通过在与用于应用程序 Common Lisp 部分的服务器不同的服务器上运行 Python 进程来实现水平扩展. 我们现在采用的方法对于我称之为 “笔记本电脑开发” 更方便, 其中 Python 进程的管理和通信由 Py4CL 库为你处理. 如果你需要构建多服务器分布式系统以实现扩展, 请使用上一章中的示例. 虽然 Py4CL 在 Common Lisp 和 Python 之间 (双向) 传递基本类型方面提供了很大的灵活性, 但我发现编写只使用列表, 数组, 数字和字符串作为参数和返回类型的小型 Python 包装器最容易. 你可能想试验 Py4CL GitHub 页面上的示例, 这些示例让你无需编写包装器即可直接调用 Python 库. 当我为自己的项目编写代码时, 我尽量使代码尽可能简单, 以便当我以后需要重新审视自己的代码时, 能立即明白它在做什么. 由于我使用 Common Lisp 将近 40 年了, 我经常发现自己重用自己旧代码的片段, 我优化以使这尽可能容易. 换句话说, 我更喜欢可读性而不是 “巧妙” 的代码.
20.1. 项目结构, 构建 Python 包装器和运行示例
我的 spacy-py4cl 包的 Lisp 代码打包很简单. 以下是此项目的 package.lisp 列表:
;;;; package.lisp (defpackage #:spacy-py4cl (:use #:cl #:py4cl) (:export #:nlp))
spacy-py4cl.asd 的列表:
;;;; spacy-py4cl.asd (asdf:defsystem #:spacy-py4cl :description "Use py4cl to use Python spaCy library embedded in Common Lisp" :author "Mark Watson <markw@markwatson.com>" :license "Apache 2" :depends-on (#:py4cl) :serial t :components ((:file "package") (:file "spacy-py4cl")))
你需要在你的系统上运行 Python 安装程序来安装 space-py4cl 的 Python 包装器. 为了简洁起见, 删除了一些输出:
$ cd loving-common-lisp/src/spacy-py4cl
$ cd PYTHON_SPACY_SETUP_install/spacystub
$ pip install -U spacy
$ python -m spacy download en
$ python setup.py install
running install
running build
running build_py
running install_lib
running install_egg_info
Writing /Users/markw/bin/anaconda3/lib/python3.7/site-packages/spacystub-0.21-py3.7.\
egg-info
除非你更新到更高版本的 Python, 否则你只需要执行一次此操作. 如果你不熟悉 Python, 值得看一下包装器的实现, 否则跳过接下来的几段.
$ ls -R PYTHON_SPACY_SETUP_install spacystub PYTHON_SPACY_SETUP_install/spacystub: README.md setup.py spacystub PYTHON_SPACY_SETUP_install/spacystub/build/lib: spacystub PYTHON_SPACY_SETUP_install/spacystub/spacystub: parse.py
以下是 setup.py 的实现, 它指定了如何构建和全局安装包装器以供你的系统使用:
from distutils.core import setup setup(name='spacystub', version='0.21', packages=['spacystub'], license='Apache 2', py_modules=['pystub'], long_description=open('README.md').read())
库在文件 PYTHONSPACYSETUPinstall/spacystub/spacystub/parse.py 中的定义:
import spacy nlp = spacy.load("en") def parse(text): doc = nlp(text) response = {} response['entities'] = [(ent.text, ent.start_char, ent.end_char, ent.label_) for e\ nt in doc.ents] response['tokens'] = [token.text for token in doc] return [response['tokens'], response['entities']]
以下是一个 Common Lisp repl 会话, 显示了如何使用下一节实现的库:
$ ccl Clozure Common Lisp Version 1.12 DarwinX8664 For more information about CCL, please see http://ccl.clozure.com. CCL is free software. It is distributed under the terms of the Apache Licence, Vers\ ion 2.0. ? (ql:quickload "spacy-py4cl") To load "spacy-py4cl": Load 1 ASDF system: spacy-py4cl ; Loading "spacy-py4cl" [package spacy-py4cl] ("spacy-py4cl") ? (spacy-py4cl:nlp "The President of Mexico went to Canada") #(#("The" "President" "of" "Mexico" "went" "to" "Canada") #(("Mexico" 17 23 "GPE") (\ "Canada" 32 38 "GPE"))) ? (spacy-py4cl:nlp "Bill Clinton bought a red car. He drove it to the beach.") #(#("Bill" "Clinton" "bought" "a" "red" "car" "." "He" "drove" "it" "to" "the" "beac\ h" ".") #(("Bill Clinton" 0 12 "PERSON")))
文本中的实体用起始和结束字符索引标识, 这些索引引用输入字符串. 例如, 实体 “Mexico” 从字符位置 17 开始, 字符索引 23 是输入字符串中实体名称之后的字符. 实体类型 “GPE” 指国家名称, “PERSON” 指人名.
20.2. spacy-py4cl 的实现
此包的 Common Lisp 实现很简单. 在第 5 行, 对 py4cl:python-exec 的调用启动一个运行 Python 的进程, 并从我的 Python 包装器导入函数 parse. 第 6 行对 py4cl:import-function 的调用在附加的 Python 进程中查找名为 “parse” 的函数, 并生成一个同名的 Common Lisp 函数, 该函数处理对 Python 的调用并转换处理返回的值为 Common Lisp 值:
;;;; spacy-py4cl.lisp (in-package #:spacy-py4cl) (py4cl:python-exec "from spacystub.parse import parse") (py4cl:import-function "parse") (defun nlp (text) (parse text))
虽然可以使用 Py4CL 直接调用 Python 库, 但当我需要经常在 Common Lisp 中使用像 spaCY, TensorFlow, fast.ai 等 Python 库时, 我喜欢使用包装器, 这些包装器使用尽可能简单的数据类型和 API 在 Common Lisp 进程和生成的 Python 进程之间进行通信.
20.3. 故障排除可能出现的问题 - 如果此示例在你的系统上有效, 请跳过
当你在命令行 (无论你的 shell 是什么, bash, zsh 等) 中安装我的包装器库到 Python 中时, 你应该然后尝试在 Python repl 中导入该库:
$ python Python 3.7.4 (default, Aug 13 2019, 15:17:50) [Clang 4.0.1 (tags/RELEASE_401/final)] :: Anaconda, Inc. on darwin Type "help", "copyright", "credits" or "license" for more information. >>> from spacystub.parse import parse >>> parse("John Smith is a Democrat") [['John', 'Smith', 'is', 'a', 'Democrat'], [('John Smith', 0, 10, 'PERSON'), ('Democ\ rat', 16, 24, 'NORP')]] >>>
如果这可行, 但 Common Lisp 库 spacy-py4cl 不行, 那么请确保你启动 Common Lisp 程序或运行 Common Lisp repl 时使用的是相同的 Python 安装 (如果你安装了 Quicklisp, 那么你也安装了 uiop 包):
$ which python /Users/markw/bin/anaconda3/bin/python $ sbcl This is SBCL 2.0.2, an implementation of ANSI Common Lisp. * (uiop:run-program "which python" :output :string) "/Users/markw/bin/anaconda3/bin/python" nil 0 *
如果你在 IDE (例如 LispWorks 的 IDE 或带有 Common Lisp 插件的 VSCode) 中运行 Common Lisp, 请确保从命令行启动 IDE, 以便你的 PATH 环境变量设置与在 bash 或 zsh shell 中一样.
20.4. 使用 Py4CL 总结
虽然我更喜欢 Common Lisp 进行通用开发和 AI 研究, 但有一些有用的 Python 库我想集成到我的项目中. 我希望上一章和本章为你提供了两种可靠的方法, 让你在自己的工作中利用 Python 库.
21. 自动生成知识图谱数据
我们开发一个完整的应用程序. 知识图谱创建器 (KGCreator) 是一个用于从原始文本数据自动生成知识图谱数据的工具. 我们将看到如何使用 SBCL Common Lisp 创建单个独立的可执行文件. 该应用程序也可以在开发过程中从 repl 运行. 此应用程序还实现了一个 Web 应用程序接口. KGCreator 创建的数据以两种格式生成:
- Neo4j 图数据库格式 (文本格式)
- RDF 三元组, 适用于加载到任何链接数据/语义网数据存储中.
这个示例应用程序通过识别文本中的实体来工作. 示例实体类型有人名, 公司名, 国家名, 城市名, 广播网络名, 政党名和大学名. 我们在自然语言处理 (NLP) 章节中看到了用于检测实体的早期代码, 我们将重用该代码. 我们稍后将讨论重用不同项目代码的三种策略. 当我最初编写 KGCreator 时, 我打算开发一个商业产品. 我编写了两个研究原型, 一个用 Common Lisp (本章的示例), 另一个用 Haskell (我也在我的书 Haskell Tutorial and Cookbook58 中用作示例). 我决定将 KGCreator 的两个版本都开源, 如果你使用知识图谱, 我希望你发现 KGCreator 在你的工作中很有用. 下图显示了使用示例代码创建的部分 Neo4j 知识图谱. 此图在显示节点中缩短了标签, 但 Neo4j 提供了一个基于 Web 浏览器的控制台, 可让你交互式地探索知识图谱. 我们在这里不介绍设置 Neo4j, 因此请使用 Neo4j 文档59. 作为对 RDF 数据, 语义网和链接数据的介绍, 你可以免费获取我的两本书 Practical Semantic Web and Linked Data Applications, Common Lisp Edition60 和 Practical Semantic Web and Linked Data Applications, Java, Scala, Clojure, and JRuby Edition61 的副本.
这是一个详细视图:
21.1. 实现说明
正如在文件 src/kgcreator/package.lisp 中看到的, 此应用程序使用了其他几个包:
(defpackage #:kgcreator (:use #:cl #:entities_dbpedia #:categorize_summarize #:myutils #:cl-who #:hunchentoot #:parenscript) (:export kgcreator))
第 3 行显示的包的实现在上一章中. 包 myutils 大多是杂项字符串实用程序, 我们在这里不再赘述; 我留给你阅读源代码.
正如在配置文件 src/kgcreator/kgcreator.asd 中看到的, 我们将应用程序的实现拆分为四个源文件:
;;;; kgcreator.asd (asdf:defsystem #:kgcreator :description "Describe plotlib here" :author "Mark Watson <mark.watson@gmail.com>" :license "AGPL version 3" :depends-on (#:entities_dbpedia #:categorize_summarize #:myutils #:unix-opts #:cl-who #:hunchentoot #:parenscript) :components ((:file "package") (:file "kgcreator") (:file "neo4j") (:file "rdf") (:file "web")) )
该应用程序分为四个源文件:
kgcreator.lisp: 顶层 API 和功能. 使用neo4j.lisp和rdf.lisp中的代码. 稍后我们将生成一个使用这些顶层 API 的独立应用程序.neo4j.lisp: 生成可以导入 Neo4j 的 Cyper 文本文件.rdf.lisp: 生成可以加载或导入 RDF 数据存储的 RDF 文本数据.web.lisp: 用于运行 KGCreator 的简单 Web 应用程序.
21.2. 生成 RDF 数据
我让你自己在网上查找 RDF 数据的教程, 或者你可以获取我的书 “Practical Semantic Web and Linked Data Applications, Common Lisp Edition”62 的 PDF 并阅读关于 RDF 的教程部分. RDF 数据由三元组组成, 每个三元组的值是一个主语, 一个谓语和一个宾语. 主语是 URI, 谓语通常是 URI, 宾语是文字值或 URI. 以下是此示例应用程序编写的两个三元组:
以下 src/kgcreator/rdf.lisp 文件的列表生成 RDF 数据:
(in-package #:kgcreator) (let ((*rdf-nodes-hash*)) (defun rdf-from-files (output-file-path text-and-meta-pairs) (setf *rdf-nodes-hash * (make-hash-table :test #'equal :size 200)) (print (list "==> rdf-from-files" output-file-path text-and-meta-pairs )) (with-open-file (str output-file-path :direction :output :if-exists :supersede :if-does-not-exist :create) (defun rdf-from-files-handle-single-file (text-input-file meta-input-file) (let* ((text (file-to-string text-input-file)) (words (myutils:words-from-string text)) (meta (file-to-string meta-input-file))) (defun generate-original-doc-node-rdf () (let ((node-name (node-name-from-uri meta))) (if (null (gethash node-name *rdf-nodes-hash*)) (let* ((cats (categorize words)) (sum (summarize words cats))) (print (list "$$$$$$ cats:" cats)) (setf (gethash node-name *rdf-nodes-hash*) t) (format str (concatenate 'string "<" meta "> <http:knowledgebooks.com/schema/summary> \"" sum "\" . ~%")) (dolist (cat cats) (let ((hash-check (concatenate 'string node-name (car cat)))) (if (null (gethash hash-check *rdf-nodes-hash*)) (let () (setf (gethash hash-check *rdf-nodes-hash*) t) (format str (concatenate 'string "<" meta "> <http://knowledgebooks.com/schema/" "topicCategory> " "<http://knowledgebooks.com/schema/" (car cat) "> . ~%")))))))))) (defun generate-dbpedia-contains-rdf (key value) (generate-original-doc-node-rdf) (let ((relation-name (concatenate 'string key "DbPediaLink"))) (dolist (entity-pair value) (let* ((node-name (node-name-from-uri meta)) (object-node-name (node-name-from-uri (cadr entity-pair))) (hash-check (concatenate 'string node-name object-node-name))) (if (null (gethash hash-check *rdf-nodes-hash*)) (let () (setf (gethash hash-check *rdf-nodes-hash*) t) (format str (concatenate 'string "<" meta "> <http://knowledgebooks.com/schema/contains/" key "> " (cadr entity-pair) " .~%")))))))))) ;; start code for rdf-from-files (output-file-path text-and-meta-pairs) (dolist (pair text-and-meta-pairs) (rdf-from-files-handle-single-file (car pair) (cadr pair)) (let ((h (entities_dbpedia:find-entities-in-text (file-to-string (car pair))\ ))) (entities_dbpedia:entity-iterator #'generate-dbpedia-contains-rdf h))))))) (defvar test_files '((#P"~/GITHUB/common-lisp/kgcreator/test_data/test3.txt" #P"~/GITHUB/common-lisp/kgcreator/test_data/test3.meta"))) (defvar test_filesZZZ '((#P"~/GITHUB/common-lisp/kgcreator/test_data/test3.txt" #P"~/GITHUB/common-lisp/kgcreator/test_data/test3.meta") (#P"~/GITHUB/common-lisp/kgcreator/test_data/test2.txt" #P"~/GITHUB/common-lisp/kgcreator/test_data/test2.meta") (#P"~/GITHUB/common-lisp/kgcreator/test_data/test1.txt" #P"~/GITHUB/common-lisp/kgcreator/test_data/test1.meta"))) (defun test3a () (rdf-from-files "out.rdf" test_files))
你可以加载 KGCreator 的所有内容, 但只需执行此文件末尾的测试函数, 使用:
(ql:quickload "kgcreator") (in-package #:kgcreator) (kgcreator:test3a)
这段代码处理一个配对文件的列表, 包括文本数据和每个文本文件的元数据. 例如, 如果有一个输入文本文件 test123.txt, 那么就会有一个匹配的元文件 test123.meta, 其中包含文件 test123.txt 中数据的来源. 这个数据源将是网络上的 URI 或本地文件 URI. 顶层函数 rdf-from-files 接受一个用于写入生成的 RDF 数据的输出文件路径以及一个文本和元文件路径对的列表.
一个全局变量 *rdf-nodes-hash* 将用于在生成 RDF 图时记住节点. 请注意, 函数 rdf-from-files 不是可重入的: 它使用全局 *rdf-nodes-hash*, 因此如果你正在编写多线程应用程序, 它将无法在多个执行线程中同时执行函数 rdf-from-files.
函数 rdf-from-files (以及嵌套函数) 非常简单. 我在代码中留下了一些调试打印语句, 当你运行我留在文件底部的测试代码时, 希望能清楚 rdf.lisp 在做什么.
21.3. 生成 Neo4j 图数据库数据
现在我们将生成 Neo4J Cypher 数据. 为了保持实现简单, RDF 和 Cypher 生成代码都从原始文本开始, 并执行 NLP 分析以查找实体. 这个例子可以重构为只执行一次 NLP 分析, 但在实践中你可能只使用 RDF 或 NEO4J, 所以你可能会从这个例子中提取你需要的代码 (即 RDF 或 Cypher 生成代码). 在看代码之前, 让我们先看几行生成的 Neo4J Cypher 导入数据:
CREATE (newsshop_com_june_z902_html_news)-[:ContainsCompanyDbPediaLink]->(Wall_Stree\
t_Journal)
CREATE (Canada:Entity {name:"Canada", uri:"<http://dbpedia.org/resource/Canada>"})
CREATE (newsshop_com_june_z902_html_news)-[:ContainsCountryDbPediaLink]->(Canada)
CREATE (summary_of_abcnews_go_com_US_violent_long_lasting_tornadoes_threaten_oklahom\
a_texas_storyid63146361:Summary {name:"summary_of_abcnews_go_com_US_violent_long_las\
ting_tornadoes_threaten_oklahoma_texas_storyid63146361", uri:"<https://abcnews.go.co\
m/US/violent-long-lasting-tornadoes-threaten-oklahoma-texas/story?id=63146361>", sum\
mary:"Part of the system that delivered severe weather to the central U.S. over the \
weekend is moving into the Northeast today, producing strong to severe storms -- dam\
aging winds, hail or isolated tornadoes can't be ruled out. Severe weather is foreca\
st to continue on Tuesday, with the western storm moving east into the Midwest and p\
arts of the mid-Mississippi Valley."})
以下 src/kgcreator/neo4j.lisp 文件的列表与上一节生成 RDF 的代码类似:
(in-package #:kgcreator) (let ((*entity-nodes-hash*)) (defun cypher-from-files (output-file-path text-and-meta-pairs) (setf *entity-nodes-hash * (make-hash-table :test #'equal :size 200)) ;;(print (list "==> cypher-from-files"output-file-path text-and-meta-pairs )) (with-open-file (str output-file-path :direction :output :if-exists :supersede :if-does-not-exist :create) (defun generateNeo4jCategoryNodes () (let* ((names categorize_summarize::categoryNames)) (dolist (name names) (format str (myutils:replace-all (concatenate 'string "CREATE (" name ":CategoryType {name:\"" name "\"})~%") "/" "_")))) (format str "~%")) (defun cypher-from-files-handle-single-file (text-input-file meta-input-file) (let* ((text (file-to-string text-input-file)) (words (myutils:words-from-string text)) (meta (file-to-string meta-input-file))) (defun generate-original-doc-node () (let ((node-name (node-name-from-uri meta))) (if (null (gethash node-name *entity-nodes-hash*)) (let* ((cats (categorize words)) (sum (summarize words cats))) (setf (gethash node-name *entity-nodes-hash*) t) (format str (concatenate 'string "CREATE (" node-name ":News {name:\"" node-name "\", uri: \"" meta "\", summary: \"" sum "\"})~%")) (dolist (cat cats) (let ((hash-check (concatenate 'string node-name (car cat)))) (if (null (gethash hash-check *entity-nodes-hash*)) (let () (setf (gethash hash-check *entity-nodes-hash*) t) (format str (concatenate 'string "CREATE (" node-name ")-[:Category]->(" (car cat) ")~%")))))))))) (defun generate-dbpedia-nodes (key entity-pairs) (dolist (entity-pair entity-pairs) (if (null (gethash (node-name-from-uri (cadr entity-pair)) *entity-nodes-hash*)) (let () (setf (gethash (node-name-from-uri (cadr entity-pair)) *entity-nodes-hash*) t) (format str (concatenate 'string "CREATE (" (node-name-from-uri (cadr entity-pair)) key " {name: \"" (car entity-pair) "\", uri: \"" (cadr entity-pair) "\"})~%")))))) (defun generate-dbpedia-contains-cypher (key value) (generate-original-doc-node) (generate-dbpedia-nodes key value) (let ((relation-name (concatenate 'string key "DbPediaLink"))) (dolist (entity-pair value) (let* ((node-name (node-name-from-uri meta)) (object-node-name (node-name-from-uri (cadr entity-pair))) (hash-check (concatenate 'string node-name object-node-name))) (if (null (gethash hash-check *entity-nodes-hash*)) (let () (setf (gethash hash-check *entity-nodes-hash*) t) (format str (concatenate 'string "CREATE (" node-name ")-[:" relation-name "]->(" object-node-name ")~%")))))))) ;; start code for cypher-from-files (output-file-path text-and-meta-pairs) (generateNeo4jCategoryNodes) ;; just once, not for every input file (dolist (pair text-and-meta-pairs) (cypher-from-files-handle-single-file (car pair) (cadr pair)) (let ((h (entities_dbpedia:find-entities-in-text (file-to-string (car pair))))) (entities_dbpedia:entity-iterator #'generate-dbpedia-contains-cypher h))))))) (defvar test_files '((#P"~/GITHUB/common-lisp/kgcreator/test_data/test3.txt" #P"~/GITHUB/common-lisp/kgcreator/test_data/test3.meta"))) (defun test2a () (cypher-from-files "out.cypher" test_files))
你可以加载 KGCreator 的所有内容, 但只需执行此文件末尾的测试函数, 使用:
(ql:quickload "kgcreator") (in-package #:kgcreator) (kgcreator:test2a)
21.4. 实现顶层应用程序 API
文件 src/kgcreator/kgcreator.lisp 中的代码使用了我们在前两节中看到的 rdf.lisp 和 neo4j.lisp. 函数 get-files-and-meta 查看输入目录的内容, 以生成一个对列表, 每对包含一个文本文件的路径和相应文本文件的元文件路径.
我们使用 opts 包来解析命令行参数. 当我们为整个 KGCreator 应用程序 (包括我们将在后面部分看到的 Web 应用程序) 构建单个文件独立可执行文件时, 将使用此功能.
;; KGCreator main program (in-package #:kgcreator) (ensure-directories-exist "temp/") (defun get-files-and-meta (fpath) (let ((data (directory (concatenate 'string fpath "/" "*.txt"))) (meta (directory (concatenate 'string fpath "/" "*.meta")))) (if (not (equal (length data) (length meta))) (let () (princ "Error: must be matching *.meta files for each *.txt file") (terpri) '()) (let ((ret '())) (dotimes (i (length data)) (setq ret (cons (list (nth i data) (nth i meta)) ret))) ret)))) (opts:define-opts (:name :help :description "KGcreator command line example: ./KGcreator -i test_data -r out.rdf -c out.cyp\ er" :short #\h :long "help") (:name :rdf :description "RDF output file name" :short #\r :long "rdf" :arg-parser #'identity ;; <- takes an argument :arg-parser #'identity) ;; <- takes an argument (:name :cypher :description "Cypher output file name" :short #\c :long "cypher" :arg-parser #'identity) ;; <- takes an argument (:name :inputdir :description "Cypher output file name" :short #\i :long "inputdir" :arg-parser #'identity)) ;; <- takes an argument (defun kgcreator () ;; don't need: &aux args sb-ext:*posix-argv*) (handler-case (let* ((opts (opts:get-opts)) (input-path (if (find :inputdir opts) (nth (1+ (position :inputdir opts)) opts))) (rdf-output-path (if (find :rdf opts) (nth (1+ (position :rdf opts)) opts))) (cypher-output-path (if (find :cypher opts) (nth (1+ (position :cypher opts)) opts)))) (format t "input-path: ~a rdf-output-path: ~a cypher-output-path:~a~%" input-path rdf-output-path cypher-output-path) (if (not input-path) (format t "You must specify an input path.~%") (locally (declare #+sbcl(sb-ext:muffle-conditions sb-kernel:redefinition-warning)) (handler-bind (#+sbcl(sb-kernel:redefinition-warning #'muffle-warning)) ;; stuff that emits redefinition-warning's (let () (if rdf-output-path (rdf-from-files rdf-output-path (get-files-and-meta input-path))) (if cypher-output-path (cypher-from-files cypher-output-path (get-files-and-meta input-path)))))))) (t (c) (format t "We caught a runtime error: ~a~%" c) (values 0 c))) (format t "~%Shutting down KGcreator - done processing~%~%")) (defun test1 () (get-files-and-meta "~/GITHUB/common-lisp/kgcreator/test_data")) (defun print-hash-entry (key value) (format t "The value associated with the key ~S is ~S~%" key value)) (defun test2 () (let ((h (entities_dbpedia:find-entities-in-text "Bill Clinton and George Bush wen\ t to Mexico and England and watched Univision. They enjoyed Dakbayan sa Dabaw and sh\ oped at Best Buy and listened to Al Stewart. They agree on República de Nicaragua a\ nd support Sweden Democrats and Leicestershire Miners Association and both sent thei\ r kids to Darul Uloom Deoband."))) (entities_dbpedia:entity-iterator #'print-hash-entry h))) (defun test7 () (rdf-from-files "out.rdf" (get-files-and-meta "test_data")))
你可以加载 KGCreator 的所有内容, 但只需执行此文件末尾的三个测试函数, 使用:
(ql:quickload "kgcreator") (in-package #:kgcreator) (kgcreator:test1) (kgcreator:test2) (kgcreator:test7)
21.5. 实现 Web 界面
当我们为 KGCreator 构建独立单文件应用程序时, 我们包含一个简单的 Web 应用程序界面, 允许用户输入文本并查看生成的 RDF 和 Neo4j Cypher 数据.
文件 src/kgcreator/web.lisp 使用了我们之前使用过的库 cl-who, hunchentoot, parenscript. 函数 write-files-run-code** (第 8-43 行) 接受原始文本, 并将生成的 RDF 和 Neo4j Cypher 数据写入本地临时文件, 然后读取这些文件并将其格式化为 HTML 以供显示. rdf.lisp 和 neo4j.lisp 中的代码是面向文件的, 我是在事后编写了 web.lisp, 因此编写临时文件比重构 rdf.lisp 和 neo4j.lisp 以写入字符串更容易.
(in-package #:kgcreator) (ql:quickload '(cl-who hunchentoot parenscript)) (setf (html-mode) :html5) (defun write-files-run-code (a-uri raw-text) (if (< (length raw-text) 10) (list "not enough text" "not enough text") ;; generate random file number (let* ((filenum (+ 1000 (random 5000))) (meta-name (concatenate 'string "temp/" (write-to-string filenum) ".meta")) (text-name (concatenate 'string "temp/" (write-to-string filenum) ".txt")) (rdf-name (concatenate 'string "temp/" (write-to-string filenum) ".rdf")) (cypher-name (concatenate 'string "temp/" (write-to-string filenum) ".cypher")) ret) ;; write meta file (with-open-file (str meta-name :direction :output :if-exists :supersede :if-does-not-exist :create) (format str a-uri)) ;; write text file (with-open-file (str text-name :direction :output :if-exists :supersede :if-does-not-exist :create) (format str raw-text)) ;; generate rdf and cypher files (rdf-from-files rdf-name (list (list text-name meta-name))) (cypher-from-files cypher-name (list (list text-name meta-name))) ;; read files and return results (setf ret (list (replace-all (replace-all (uiop:read-file-string rdf-name) ">" ">") "<" "<") (uiop:read-file-string cypher-name))) (print (list "ret:" ret)) ret))) (defvar *h * (make-instance 'easy-acceptor :port 3000)) ;; define a handler with the arbitrary name my-greetings: (define-easy-handler (my-greetings :uri "/") (text) (setf (hunchentoot:content-type*) "text/html") (let ((rdf-and-cypher (write-files-run-code "http://test.com/1" text))) (print (list "** * rdf-and-cypher:" rdf-and-cypher)) (with-html-output-to-string (*standard-output * nil :prologue t) (:html (:head (:title "KGCreator Demo") (:link :rel "stylesheet" :href "styles.css" :type "text/css")) (:body :style "margin: 90px" (:h1 "Enter plain text for the demo to create RDF and Cypher") (:p "For more information on the KGCreator product please visit the web site:" (:a :href "https://markwatson.com/products/" "Mark Watson's commercial products"\ )) (:p "The KGCreator product is a command line tool that processes all text " "web applications and files in a source directory and produces both RDF data " "triples for semantic Cypher input data files for the Neo4j graph database. " "For the purposes of this demo the URI for your input text is hardwired to " "<http://test.com/1> but the KGCreator product offers flexibility " "for assigning URIs to data sources and further, " "creates links for relationships between input sources.") (:p :style "text-align: left" "To try the demo paste plain text into the following form that contains " "information on companies, news, politics, famous people, broadcasting " "networks, political parties, countries and other locations, etc. ") (:p "Do not include and special characters or character sets:") (:form :method :post (:textarea :rows "20" :cols "90" :name "text" :value text) (:br) (:input :type :submit :value "Submit text to process")) (:h3 "RDF:") (:pre (str (car rdf-and-cypher))) (:h3 "Cypher:") (:pre (str (cadr rdf-and-cypher)))))))) (defun kgcweb () (hunchentoot:start *h*))
你可以加载 KGCreator 的所有内容并启动 Web 应用程序, 使用:
(ql:quickload "kgcreator") (in-package #:kgcreator) (kgcweb)
你可以在 http://localhost:3000[fn:71] 访问 Web 应用程序.
21.6. 使用 SBCL 创建独立应用程序
当我最初编写 KGCreator 时, 我打算开发一个商业产品, 因此能够创建独立的单文件可执行文件很重要. 使用 SBCL 可以轻松做到这一点:
$ sbcl (ql:quickload "kgcreator") (in-package #:kgcreator) (sb-ext:save-lisp-and-die "KGcreator" :toplevel #'kgcreator :executable t)
例如, 你可以使用命令行运行应用程序:
./KGcreator -i test_data -r out.rdf -c out.cyper
21.7. KGCreator 总结
在开发使用知识图谱的应用程序或系统时, 能够快速生成测试数据很有用, 这是 KGCreator 的主要目的. 第二个用途是使用文本数据源为生产环境生成知识图谱. 在这第二个用例中, 你需要手动检查生成的数据以验证其对你的应用程序的正确性或有用性.
22. 知识图谱导航器
知识图谱导航器 (我通常称之为 KGN) 是一个用于处理一组实体名称并通过 SPARQL 查询自动探索公共知识图谱 DBPedia63 的工具. 我最初是为了自己的用途编写 KGN, 以自动化一些我在探索知识图谱时手动执行的操作, 后来认为 KGN 可能对教育目的也有用. KGN 向用户显示自动生成的 SPARQL 查询, 以便用户可以通过查看示例来学习. KGN 使用了前面章节开发的 NLP 代码, 我们将重用该代码并简要回顾其 API 的使用.
在查看了应用程序示例查询使用的生成 SPARQL 之后, 我们将开始自下而上的开发过程, 首先编写低级函数来自动化 SPARQL 查询, 编写我们将用于 UI 的实用程序, 最后编写 UI. 在此过程中, 我们需要解决的一些问题包括对用户在 UI 中看到的输出进行着色, 以及实现进度条, 以便应用程序用户不会认为应用程序在生成和向 DBPedia 发出 SPARQL 查询时 “挂起”. 由于 DBPedia 查询非常耗时, 我们还将使用 SQLite 实现一个缓存层, 这将使应用程序响应更快. 在开发过程中, 当重复使用相同的查询进行测试时, 缓存尤其有用.
22.1. 示例输出
在开始研究实现之前, 让我们先看一下示例输出, 以便有助于理解我们稍后将要查看的代码. 考虑用户可能在 KGN 应用程序顶部查询字段中键入的查询:
Steve Jobs lived near San Francisco and was a founder of \<http://dbpedia.org/resource/Apple_Inc.\>
系统将尝试识别查询中的实体. 如果你知道实体的 DBPedia URI, 就像本例中的 Apple 公司一样, 你可以直接使用它. 请注意, 在 SPARQL 中, URI 被尖括号字符包围. 应用程序会打印出自动生成的 SPARQL 查询. 对于上面列出的示例查询, 将生成以下输出 (进行了一些编辑以适应页面宽度):
Trying to get entity by name = Steve Jobs using SPARQL with type:
select distinct ?s ?comment { ?s ?p "Steve Jobs"@en .
?s <http://www.w3.org/2000/01/rdf-schema#comment> ?comment .
FILTER ( lang ( ?comment ) = 'en' ) .
?s <http://www.w3.org/1999/02/22-rdf-syntax-ns#type>
<http://dbpedia.org/ontology/Person> .
} LIMIT 15
Trying to get entity by name = San Francisco using SPARQL with type:
select distinct ?s ?comment { ?s ?p "San Francisco"@en .
?s <http://www.w3.org/2000/01/rdf-schema#comment> ?comment .
FILTER ( lang ( ?comment ) = 'en' ) .
?s <http://www.w3.org/1999/02/22-rdf-syntax-ns#type>
<http://dbpedia.org/ontology/City> .
} LIMIT 15
SPARQL to get PERSON data for <http://dbpedia.org/resource/Steve_Jobs>:
SELECT DISTINCT ?label ?comment
( GROUP_CONCAT ( DISTINCT ?birthplace ; SEPARATOR=' | ' ) AS ?birthplace )
( GROUP_CONCAT ( DISTINCT ?almamater ; SEPARATOR=' | ' ) AS ?almamater )
( GROUP_CONCAT ( DISTINCT ?spouse ; SEPARATOR=' | ' ) AS ?spouse ) {
<http://dbpedia.org/resource/Steve_Jobs>
<http://www.w3.org/2000/01/rdf-schema#comment>
?comment .
FILTER ( lang ( ?comment ) = 'en' ) .
OPTIONAL { <http://dbpedia.org/resource/Steve_Jobs>
<http://dbpedia.org/ontology/birthPlace>
?birthplace } .
OPTIONAL { <http://dbpedia.org/resource/Steve_Jobs>
<http://dbpedia.org/ontology/almaMater>
?almamater } .
OPTIONAL { <http://dbpedia.org/resource/Steve_Jobs>
<http://dbpedia.org/ontology/spouse>
?spouse } .
OPTIONAL { <http://dbpedia.org/resource/Steve_Jobs>
<http://www.w3.org/2000/01/rdf-schema#label>
?label .
FILTER ( lang ( ?label ) = 'en' ) }
} LIMIT 10
SPARQL to get CITY data for <http://dbpedia.org/resource/San_Francisco>:
SELECT DISTINCT ?label ?comment
( GROUP_CONCAT ( DISTINCT ?latitude_longitude ; SEPARATOR=' | ' )
AS ?latitude_longitude )
( GROUP_CONCAT ( DISTINCT ?populationDensity ; SEPARATOR=' | ' )
AS ?populationDensity )
( GROUP_CONCAT ( DISTINCT ?country ; SEPARATOR=' | ' )
AS ?country ) {
<http://dbpedia.org/resource/San_Francisco>
<http://www.w3.org/2000/01/rdf-schema#comment>
?comment .
FILTER ( lang ( ?comment ) = 'en' ) .
OPTIONAL { <http://dbpedia.org/resource/San_Francisco>
<http://www.w3.org/2003/01/geo/wgs84_pos#geometry>
?latitude_longitude } .
OPTIONAL { <http://dbpedia.org/resource/San_Francisco>
<http://dbpedia.org/ontology/PopulatedPlace/populationDensity>
?populationDensity } .
OPTIONAL { <http://dbpedia.org/resource/San_Francisco>
<http://dbpedia.org/ontology/country>
?country } .
OPTIONAL { <http://dbpedia.org/resource/San_Francisco>
<http://www.w3.org/2000/01/rdf-schema#label>
?label . }
} LIMIT 30
SPARQL to get COMPANY data for <http://dbpedia.org/resource/Apple_Inc.>:
SELECT DISTINCT ?label ?comment ( GROUP_CONCAT ( DISTINCT ?industry ; SEPARATOR=' | \
' )
AS ?industry )
( GROUP_CONCAT ( DISTINCT ?netIncome ; SEPARATOR=' | ' )
AS ?netIncome )
( GROUP_CONCAT ( DISTINCT ?numberOfEmployees ; SEPARATOR=' | ' )
AS ?numberOfEmployees ) {
<http://dbpedia.org/resource/Apple_Inc.>
<http://www.w3.org/2000/01/rdf-schema#comment> ?comment .
FILTER ( lang ( ?comment ) = 'en' ) .
OPTIONAL { <http://dbpedia.org/resource/Apple_Inc.>
<http://dbpedia.org/ontology/industry>
?industry } .
OPTIONAL { <http://dbpedia.org/resource/Apple_Inc.>
<http://dbpedia.org/ontology/netIncome> ?netIncome } .
OPTIONAL { <http://dbpedia.org/resource/Apple_Inc.>
<http://dbpedia.org/ontology/numberOfEmployees> ?numberOfEmployees } .
OPTIONAL { <http://dbpedia.org/resource/Apple_Inc.>
<http://www.w3.org/2000/01/rdf-schema#label> ?label .
FILTER ( lang ( ?label ) = 'en' ) }
} LIMIT 30
DISCOVERED RELATIONSHIP LINKS:
<http://dbpedia.org/resource/Steve_Jobs> ->
<http://dbpedia.org/ontology/birthPlace> ->
<http://dbpedia.org/resource/San_Francisco>
<http://dbpedia.org/resource/Steve_Jobs> ->
<http://dbpedia.org/ontology/occupation> ->
<http://dbpedia.org/resource/Apple_Inc.>
<http://dbpedia.org/resource/Steve_Jobs> ->
<http://dbpedia.org/ontology/board> ->
<http://dbpedia.org/resource/Apple_Inc.>
<http://dbpedia.org/resource/Steve_Jobs> ->
<http://www.w3.org/2000/01/rdf-schema#seeAlso> ->
<http://dbpedia.org/resource/Apple_Inc.>
<http://dbpedia.org/resource/Apple_Inc.> ->
<http://dbpedia.org/property/founders> ->
<http://dbpedia.org/resource/Steve_Jobs>
在列出用于查找查询中实体信息的生成 SPARQL 之后, KGN 会搜索这些实体之间的关系. 这些发现的关系可以在上一个列表的末尾看到. 请注意, 此步骤使 SPARQL 查询的数量达到 O(n2), 其中 n 是实体的数量. 对 DBPedia 的 SPARQL 查询进行本地缓存有助于使处理多个实体成为可能. 除了在应用程序的中间文本窗格中显示生成的 SPARQL 和发现的关系之外, KGN 还生成格式化的结果, 这些结果也显示在底部文本窗格中:
- - - ENTITY TYPE: PEOPLE - - - LABEL: Steve Jobs COMMENT: Steven Paul "Steve" Jobs was an American information technology entrepreneur and inventor. He was the co-founder, chairman, and chief executive officer (CEO) of Apple Inc.; CEO and majority shareholder of Pixar Animation Studios; a member of The Walt Disney Company's board of directors following its acquisition of Pixar; and founder, chairman, and CEO of NeXT Inc. Jobs is widely recognized as a pioneer of the microcomputer revolution of the 1970s and 1980s, along with Apple co-founder Steve Wozniak. Shortly after his death, Jobs's official biographer, Walter Isaacson, described him as a "creative entrepreneur whose passion for perfection and ferocious drive revolutionized six industries: personal computers, animated movies, music, phones BIRTHPLACE: http://dbpedia.org/resource/San_Francisco ALMAMATER: http://dbpedia.org/resource/Reed_College SPOUSE: http://dbpedia.org/resource/Laurene_Powell_Jobs - - - ENTITY TYPE: CITIES - - - LABEL: San Francisco COMMENT: San Francisco, officially the City and County of San Francisco, is the cultural, commercial, and financial center of Northern California and the only consolidated city-county in California. San Francisco encompasses a land area of about 46.9 square miles (121 km2) on the northern end of the San Francisco Peninsula, which makes it the smallest county in the state. It has a density of about 18,451 people per square mile (7,124 people per km2), making it the most densely settled large city (population greater than 200,000) in the state of California and the second-most densely populated major city in the United States after New York City. San Francisco is the fourth-most populous city in California, after Los Angeles, San Diego, and San Jose, and the 13th-most populous cit LATITUDE--LONGITUDE: POINT(-122.41666412354 37.783332824707) POPULATION-DENSITY: 7123.97092726667 COUNTRY: http://dbpedia.org/resource/United_States - - - ENTITY TYPE: COMPANIES - - - LABEL: Apple Inc. COMMENT: Apple Inc. is an American multinational technology company headquartered in Cupertino, California, that designs, develops, and sells consumer electronics, computer software, and online services. Its hardware products include the iPhone smartphone, the iPad tablet computer, the Mac personal computer, the iPod portable media player, the Apple Watch smartwatch, and the Apple TV digital media player. Apple's consumer software includes the macOS and iOS operating systems, the iTunes media player, the Safari web browser, and the iLife and iWork creativity and productivity suites. Its online services include the iTunes Store, the iOS App Store and Mac App Store, Apple Music, and iCloud. INDUSTRY: http://dbpedia.org/resource/Computer_hardware | http://dbpedia.org/resource/Computer_software | http://dbpedia.org/resource/Consumer_electronics | http://dbpedia.org/resource/Corporate_Venture_Capital | http://dbpedia.org/resource/Digital_distribution | http://dbpedia.org/resource/Fabless_manufacturing NET-INCOME: 5.3394E10 NUMBER-OF-EMPLOYEES: 115000
希望在阅读了示例输出并看到了应用程序的屏幕截图后, 你现在对这个示例应用程序的功能有了更好的了解. 现在我们将看一下项目配置, 然后是实现.
22.2. 项目配置和运行应用程序
以下 kgn.asd 的列表显示了此示例依赖的十个包 (其中五个也是本书中的示例, 五个在公共 Quicklisp 存储库中):
;;;; knowledgegraphnavigator.asd (asdf:defsystem #:kgn :description "Describe dbpedia here" :author "Mark Watson <markw@markwatson.com>" :license "Apache 2" :depends-on (#:sqlite #:cl-json #:alexandria #:drakma #:myutils #:lw-grapher #:trivial-open-browser #:entities #:entity-uris #:kbnlp) :components ((:file "package") (:file "ui-text") (:file "utils") (:file "sparql") (:file "colorize") (:file "user-interface") (:file "option-pane") (:file "kgn") (:file "gui") (:file "nlp") (:file "sparql-results-to-english") (:file "gen-output")))
你可能已经熟悉这里使用的许多依赖库, 但你可能没有见过 trivial-open-browser, 我们将使用它来为 DBPedia 上的可读信息打开一个 Web 浏览器指向 URI.
package.lisp 的列表:
;;;; package.lisp (defpackage #:kgn (:use #:cl #:alexandria #:myutils #:sqlite #:myutils #:lw-grapher #:trivial-open-browser #:entities #:entity-uris #:kbnlp #:CAPI) (:export #:kgn))
免费个人版的 LispWorks 不支持初始化文件, 因此当你第一次启动 LispWorks Personal 时, 必须从 Listener 窗口手动加载 Quicklisp, 如以下 repl 列表所示 (为简洁起见, 删除了一些输出). 加载 Quicklisp 后, 我们然后使用 ql:quickload 加载本章中的示例 (为简洁起见, 删除了一些输出):
CL-USER 1 > (load "~/quicklisp/setup.lisp") ; Loading text file /Users/markw/quicklisp/setup.lisp ; Loading /Applications/LispWorks Personal 7.1/... ;; Creating system "COMM" #P"/Users/markw/quicklisp/setup.lisp" CL-USER 2 > (ql:quickload "kgn") To load "kgn": Load 1 ASDF system: kgn ; Loading "kgn" . "Starting to load data...." "....done loading data." "#P\"/Users/markw/GITHUB/common-lisp/entity-uris/entity-uris.lisp\"" "current directory:" "/Users/markw/GITHUB/common-lisp/entity-uris" "Starting to load data...." "....done loading data." [package kgn] To load "sqlite": Load 1 ASDF system: sqlite ; Loading "sqlite" To load "cl-json": Load 1 ASDF system: cl-json ; Loading "cl-json" To load "drakma": Load 1 ASDF system: drakma ; Loading "drakma" .To load "entity-uris": Load 1 ASDF system: entity-uris ; Loading "entity-uris" ("kgn") CL-USER 3 > (kgn:kgn) #<KGN::KGN-INTERFACE "Knowledge Graph Navigator" 40201E91DB>
请注意, 我假设你已按照附录 A 中 Setup for Local Quicklisp Projects 部分的说明配置了本书的所有示例, 以便 Quicklisp 可以发现它们.
当 KGN 应用程序启动时, 会随机选择一个示例查询. 包含许多实体的查询处理起来可能需要一段时间, 特别是当你第一次使用此应用程序时. KGN 每次对 DBPedia 进行 Web 服务调用时, 查询和响应都会缓存在 ∼/.kgn_cache.db 的 SQLite 数据库中, 这可以大大加快程序速度, 特别是在测试一组查询的开发模式下. 这种缓存还减轻了公共 DBPedia 端点的一些负载, 这是一种礼貌的做法.
我使用 LispWorks Professional, 并在我的 ∼/.lispworks 配置文件底部添加了两个实用函数 (你不能使用 LispWorks Personal 执行此操作):
;;; The following lines added by ql:add-to-init-file: #-quicklisp (let ((quicklisp-init (merge-pathnames "quicklisp/setup.lisp" (user-homedir-pathname)))) (when (probe-file quicklisp-init) (load quicklisp-init))) (defun ql (x) (ql:quickload x)) (defun qlp (x) (ql:quickload x) (SYSTEM::%IN-PACKAGE (string-upcase x) :NEW T))
函数 ql 只是一个简短的别名, 以避免频繁键入 ql:quickload, 而 qlp 加载一个 Quicklisp 项目, 然后执行与 Quicklisp 项目同名的 Common Lisp 包的 in-package.
22.3. 应用程序中使用的 NLP 实用程序回顾
以下是对我们之前看到的 NLP 实用程序的快速回顾:
kbnlp:make-text-objectkbnlp::text-human-nameskbnlp::text-place-nameentity-uris:find-entities-in-textentity-uris:pp-entities
以下代码片段显示了相关 NLP 函数的示例调用和生成的输出:
KGN 39 > (setf text "Bill Clinton went to Canada") "Bill Clinton went to Canada" KGN 40 > (setf txtobj (kbnlp:make-text-object text)) #S(TEXT :URL "" :TITLE "" :SUMMARY "<no summary>" :CATEGORY-TAGS (("computers_micros\ oft.txt" 0.00641) ("religion_islam.txt" 0.00357)) :KEY-WORDS NIL :KEY-PHRASES NIL :H\ UMAN-NAMES ("Bill Clinton") :PLACE-NAMES ("Canada") :COMPANY-NAMES NIL :TEXT #("Bill\ " "Clinton" "went" "to" "Canada") :TAGS #("NNP" "NNP" "VBD" "TO" "NNP")) KGN 41 > (kbnlp::text-human-names txtobj) ("Bill Clinton") KGN 42 > (loop for key being the hash-keys of (entity-uris:find-entities-in-text text) using (hash-value value) do (format t "key: ~S value: ~S~%" key value)) key: "people" value: (("Bill Clinton" "<http://dbpedia.org/resource/Bill_Clinton>")) key: "countries" value: (("Canada" "<http://dbpedia.org/resource/Canada>")) NIL
上一个 repl 列表末尾使用 loop 打印哈希表的键和值的代码来自 Common Lisp Cookbook 网站64 的 “Traversing a Hash Table” 部分.
22.4. 开发低级 SPARQL 实用程序
我使用标准的命令行 curl 实用程序和 Common Lisp 包 uiop 来向 DBPedia 公共知识图谱发出 HTML GET 请求, 并使用 drakma 包对查询的某些部分进行 url 编码. 源代码位于 src/kgn/sparql.lisp. 在第 8, 24, 39 和 55 行, 我使用了一些我们稍后会看的缓存代码. 第 12-13 行中嵌套的 replace-all 语句是一个权宜之计, 用于删除偶尔在 KGN 应用程序中导致运行时错误的 Unicode 字符.
(in-package #:kgn) (ql:quickload "cl-json") (ql:quickload "drakma") (defun sparql-dbpedia (query) (let* (ret (cr (fetch-result-dbpedia query)) (response (or cr (replace-all (replace-all (uiop:run-program (list "curl" (concatenate 'string "https://dbpedia.org/sparql?query=" (drakma:url-encode query :utf-8) "&format=json")) :output :string) "\\u2013" " ") "\\u" " ")))) (save-query-result-dbpedia query response) (ignore-errors (with-input-from-string (s response) (let ((json-as-list (json:decode-json s))) (setf ret (mapcar #'(lambda (x) ;;(pprint x) (mapcar #'(lambda (y) (list (car y) (cdr (assoc :value (cdr y))))) x)) (cdr (cadddr (cadr json-as-list)))))))) ret)) (defun sparql-ask-dbpedia (query) (let* ((cr (fetch-result-dbpedia query)) (response (or cr (replace-all (replace-all (uiop:run-program (list "curl" (concatenate 'string "https://dbpedia.org/sparql?query=" (drakma:url-encode query :utf-8) "&format=json")) :output :string) "\\u2013" " ") "\\u" " ")))) (save-query-result-dbpedia query response) (if (search "true" response) t nil)))
用于替换 Unicode 字符的代码很混乱, 但可以防止我们在示例应用程序中使用查询结果时稍后出现问题.
第 28 行的代码 (json-as-list (json:decode-json s)) 将深度嵌套的 JSON 响应转换为嵌套的 Common Lisp 列表. 你可能想打印出该列表以更好地理解第 31-35 行的 mapcar 表达式. 编写这样的表达式没有什么魔力, 在 repl 中我将 json-as-list 设置为一个查询的结果, 并花了一两分钟试验嵌套的 mapcar 表达式以使其适用于我的测试用例.
第 38-58 行 sparql-ask-dbpedia 的实现更简单, 因为我们不必完全解析返回的 SPARQL 查询结果. SPARQL ask 类型查询返回对查询的 true/false 答案. 我们将使用它来确定查询文本中实体的类型. 虽然我们的 NLP 库可以识别实体类型, 但向 DBPedia 发出额外的 ask 查询以验证实体类型将提供更好的自动化结果.
22.5. 实现缓存层
在开发 KGN 以及作为最终用户使用它时, 许多对 DBPedia 的 SPARQL 查询都包含重复的实体名称, 因此编写缓存层是有意义的. 我们使用 SQLite 数据库 “=~/.kgncache.db=” 来存储查询和响应.
缓存层实现在文件 kgn/utils.lisp 中, 相关代码部分列在此处:
;;; SqList caching for SPARQL queries: (defvar *db-path * (pathname "~/.kgn_cache.db")) (defun create-dbpedia () (sqlite:with-open-database (d *db-path*) (ignore-errors (sqlite:execute-single d "CREATE TABLE dbpedia (query string PRIMARY KEY ASC, result string)")))) (defun save-query-result-dbpedia (query result) (sqlite:with-open-database (d *db-path*) (ignore-errors (sqlite:execute-to-list d "insert into dbpedia (query, result) values (?, ?)" query result)))) (defun fetch-result-dbpedia (query) (sqlite:with-open-database (d *db-path*) (cadar (sqlite:execute-to-list d "select * from dbpedia where query = ?" query))))
这个缓存层极大地加快了我个人使用 KGN 的速度. 如果没有缓存, 包含许多实体引用的查询运行时间太长. KGN 应用程序的 UI 有一个用于清除本地缓存的菜单选项, 但我几乎从不使用此选项, 因为根据我搜索的信息类型量身定制的大型缓存使整个系统响应更快.
22.6. 用于着色 SPARQL 和生成输出的实用程序
当我第一次实现 KGN 的基本功能时, 我对应用程序的外观感到失望, 因为所有文本都是白色背景上的黑色文本. 我使用的每个编辑器和 IDE 都以适当的方式对文本进行着色, 所以我利用了函数 capi::write-string-with-properties 来 (相当) 轻松地实现 SPARQL 查询的颜色高亮.
以下列表中的代码位于文件 kgn/colorize.lisp 中. 当我生成 SPARQL 查询以向用户显示时, 我使用字符 “@@” 作为生成输出中行尾的占位符. 在第 5 行, 我确保这些字符周围有空格, 以便它们被正确地分词. 在从第 7 行开始的循环中, 我处理标记, 检查每个标记, 看它在写入输出流时是否应该有关联的颜色.
(in-package #:kgn) (defun colorize-sparql (s &key (stream nil)) (let ((tokens (tokenize-string-keep-uri (replace-all s "@@" " @@ "))) in-var) (dolist (token tokens) (if (> (length token) 0) (if (or in-var (equal token "?")) (capi::write-string-with-properties token '(:highlight :compiler-warning-highlight) stream) (if (find token '("where" "select" "distinct" "option" "filter" "FILTER" "OPTION" "DISTINCT" "SELECT" "WHERE") :test #'equal) (capi::write-string-with-properties token '(:highlight :compiler-note-highlight) stream) (if (equal (subseq token 0 1) "<") (capi::write-string-with-properties token '(:highlight :bold) stream) (if (equal token "@@") (terpri stream) (if (not (equal token "~")) (write-string token stream))))))) (if (equal token "?") (setf in-var t) (setf in-var nil)) (if (and (not in-var) (not (equal token "?"))) (write-string " " stream))) (terpri stream)))
以下是对函数 colorize-sparql 的示例调用:
KGN 25 > (colorize-sparql "select ?s ?p where {@@ ?s ?p \"Microsoft\" } @@ FILTER\ (lang(?comment) = 'en')") select ?s ?p where { ?s ?p "Microsoft" } FILTER ( lang ( ?comment ) = 'en' )
22.7. 用于查询和结果的文本实用程序
文件 kgn/ui-text.lisp 中的实用程序不包含 CAPI UI 代码, 但被 CAPI UI 代码使用. 函数 display-entity-results 传递一个输出流, 在 repl 开发期间传递 t 以在 repl 中获取输出, 在应用程序中将是附加到文本窗格的输出流. 参数 r-list 是一个结果列表, 其中每个结果是一个包含结果标题和键/值对列表的列表:
(defun display-entity-results (output-stream r-list) (dolist (r r-list) (format output-stream "~%~%entity result:~%~S~%" r) (dolist (val r) (if (> (length (second val)) 0) (format output-stream "~%~a: ~a~%" (first val) (second val)))))) (defun get-URIs-in-query (query) ;; URIs contain < > brackets (let (ret w (ll (coerce query 'list)) in-uri) (dolist (ch ll) (if in-uri (if (equal ch #\>) (setf w (cons ch w) ret (cons (coerce (reverse w) 'string) ret) in-uri nil w nil) (setf w (cons ch w)))) (if (equal ch #\<) (setf in-uri t w (cons #\< w)))) ret))
第 8-23 行的函数 get-URIs-in-query 只是查找 URI 并将它们保存在一个列表中.
在 SPARQL 查询中, URI 被尖括号包围. 以下代码移除括号和嵌入的 URI. 函数 remove-uris-from-query 只是查找输入字符串中的 URI 并移除它们:
(defun remove-uris-from-query (query) ;; URIs contain < > brackets (let (ret (ll (coerce query 'list)) in-uri) (dolist (ch ll) (if (equal ch #\<) (setf in-uri t)) (if (not in-uri) (setf ret (cons ch ret))) (if (equal ch #\>) (setf in-uri nil))) (coerce (reverse ret) 'string)))
这是一个测试:
KGN 26 > (remove-uris-from-query "<http://dbpedia.org/resource/Bill_Gates> visited <http://dbpedia.org/resource/Appl\ e_Inc.>") " visited "
给定一个 URI 列表, 以下函数进行多次 SPARQL 查询到 DBPedia 以获取更多信息, 使用我们稍后将看到的函数 get-name-and-description-for-uri:
(defun handle-URIs-in-query (query) (let* ((uris (get-URIs-in-query query)) (entity-names (map 'list #'get-name-and-description-for-uri uris))) (mapcar #'list uris (map 'list #'second entity-names))))
以下 repl 显示了对 handle-URIs-in-query 的调用:
KGN 30 > (pprint (handle-URIs-in-query "<http://dbpedia.org/resource/Bill_Gates> vis\ ited <http://dbpedia.org/resource/Apple_Inc.>")) (("<http://dbpedia.org/resource/Apple_Inc.>" "Apple Inc. is an American multinational technology company headquartered in Cuper\ tino, California, that designs, develops, and sells consumer electronics, computer s\ oftware, and online services. Its hardware products include the iPhone smartphone, t\ he iPad tablet computer, the Mac personal computer, the iPod portable media player, \ the Apple Watch smartwatch, and the Apple TV digital media player. Apple's consumer \ software includes the macOS and iOS operating systems, the iTunes media player, the \ Safari web browser, and the iLife and iWork creativity and productivity suites. Its \ online services include the iTunes Store, the iOS App Store and Mac App Store, Apple\ Music, and iCloud.") ("<http://dbpedia.org/resource/Bill_Gates>" "William Henry \"Bill\" Gates III (born October 28, 1955) is an American business \ magnate, investor, author and philanthropist. In 1975, Gates and Paul Allen co-found\ ed Microsoft, which became the world's largest PC software company. During his caree\ r at Microsoft, Gates held the positions of chairman, CEO and chief software archite\ ct, and was the largest individual shareholder until May 2014. Gates has authored an\ d co-authored several books."))
函数 get-entity-data-helper 处理用户的查询并使用本书前面介绍的 NLP 实用程序以及通过 SPARQL 查询 DBPedia 来查找实体. 新的内容是调用函数 updater (第 10-13, 17-20 和 29-31 行), 该函数定义为可选参数. 正如我们稍后将看到的, 我们将在应用程序中传递一个函数值, 该函数值更新应用程序窗口底部的进度条.
(defun get-entity-data-helper (original-query &key (message-stream t) (updater nil)) (let* ((uri-data (handle-URIs-in-query original-query)) (query (remove-uris-from-query original-query)) ret (el (entities:text->entities query)) (people (entities:entities-people el))) (if updater (let () (setf *percent * (+ *percent * 2)) (funcall updater *percent*))) (let* ((companies (entities:entities-companies el)) (countries (entities:entities-countries el)) (cities (entities:entities-cities el))) (if updater (let () (setf *percent * (+ *percent * 2)) (funcall updater *percent*))) (let* ((products (entities:entities-products el)) places companies-uri people-uri countries-uri cities-uri places-uri (text-object (kbnlp:make-text-object query)) (to-place-names (kbnlp::text-place-names text-object)) (to-people (kbnlp::text-human-names text-object))) (if updater (let () (setf *percent * (+ *percent * 3)) (funcall updater *percent*))) (dolist (ud uri-data) (if (ask-is-type-of (first ud) "<http://dbpedia.org/ontology/Company>") (setf companies-uri (cons ud companies-uri))) (if (ask-is-type-of (first ud) "<http://dbpedia.org/ontology/Person>") (setf people-uri (cons ud people-uri))) (if (ask-is-type-of (first ud) "<http://dbpedia.org/ontology/Country>") (setf countries-uri (cons ud countries-uri))) (if (ask-is-type-of (first ud) "<http://dbpedia.org/ontology/City>") (setf cities-uri (cons ud cities-uri))) (if (ask-is-type-of (first ud) "<http://dbpedia.org/ontology/Place>") (setf places-uri (cons ud places-uri)))) (dolist (place to-place-names) (if (and (not (member place countries :test #'equal)) (not (member place cities :test #'equal))) (setf places (cons place places)))) (dolist (person to-people) (if (not (member person people :test #'equal)) (setf people (cons person people)))) (let ((entity-list (list (cons :people (append (loop for person in people collect (dbpedia-get-entities-by-name person "<http://dbpedia.org/ontology/Person>" "<http://schema.org/Person>" :message-stream message-stream)) (list people-uri))) (cons :countries (append (loop for country in countries collect (dbpedia-get-entities-by-name country "<http://dbpedia.org/ontology/Country>" "<http://schema.org/Country>" :message-stream message-stream)) (list countries-uri))) (cons :cities (append (loop for city in cities collect (dbpedia-get-entities-by-name city "<http://dbpedia.org/ontology/City>" "<http://schema.org/City>" :message-stream message-stream)) (list cities-uri))) (cons :places (append (loop for place in places collect (dbpedia-get-entities-by-name place "<http://dbpedia.org/ontology/Place>" "<http://schema.org/Place>" :message-stream message-stream)) (list places-uri))) (cons :products (loop for product in products collect (dbpedia-get-entities-by-name product "<http://dbpedia.org/ontology/Product>" "<http://schema.org/Product>" :message-stream message-stream))) (cons :companies (append (loop for company in companies collect (dbpedia-get-entities-by-name company "<http://dbpedia.org/ontology/Organization>" "<http://schema.org/Organization>" :message-stream message-stream)) (list companies-uri)))))) (setf ret (prompt-selection-list entity-list)) (format t "~%~%--------- ret:~%~%~S~%~%" ret) ret)))))
此函数向用户呈现一个 CAPI 弹出列表选择器, 因此以下列出的输出取决于在此列表中选择了哪些可能的实体. 如果你运行以下 repl 示例, 你将看到一个弹出窗口, 要求你验证发现的实体; 用户需要检查所有与其兴趣相关的已发现实体.
KGN 33 > (pprint (get-entity-data-helper "Bill Gates at Microsoft")) ((:PEOPLE (("<http://dbpedia.org/resource/Bill_Gates>" "William Henry \"Bill\" Gates III (born October 28, 1955) is an American busines\ s magnate, investor, author and philanthropist. In 1975, Gates and Paul Allen co-fou\ nded Microsoft, which became the world's largest PC software company. During his car\ eer at Microsoft, Gates held the positions of chairman, CEO and chief software archi\ tect, and was the largest individual shareholder until May 2014. Gates has authored \ and co-authored several books."))) (:COMPANIES (("<http://dbpedia.org/resource/Microsoft>" "Microsoft Corporation / 02C8ma 026Akr 0259 02CCs 0252ft, -ro 028A-, - 02CCs 025\ 4 02D0ft/ (commonly referred to as Microsoft or MS) is an American multinational tec\ hnology company headquartered in Redmond, Washington, that develops, manufactures, l\ icenses, supports and sells computer software, consumer electronics and personal com\ puters and services. Its best known software products are the Microsoft Windows line\ of operating systems, Microsoft Office office suite, and Internet Explorer and Edge\ web browsers. Its flagship hardware products are the Xbox video game consoles and t\ he Microsoft Surface tablet lineup. As of 2011, it was the world's largest software \ maker by revenue, and one of the world's most valuable companies."))))
上一个示例中的弹出列表如下所示:
在这个例子中有两个 “Bill Gates” 实体, 一个是早期的美国拓荒者, 另一个是微软的创始人, 我选择了后者继续查找信息.
在识别了用户意图的所有实体之后, 会调用以下列表中的函数 entity-results->relationship-link 来进行额外的 SPARQL 查询, 以发现这些实体之间可能的关系. 此函数定义在文件 ui-text.lisp 中.
(defun entity-results->relationship-links (results &key (message-stream t) (updater nil)) (let (all-uris relationship-statements (sep " -> ")) (dolist (r results) (dolist (entity-data (cdr r)) (dolist (ed entity-data) (setf all-uris (cons (first ed) all-uris))))) (dolist (e1 all-uris) (dolist (e2 all-uris) (if updater (let () (setf *percent * (+ *percent * 1)) (funcall updater *percent*))) (if (not (equal e1 e2)) (let ((l1 (dbpedia-get-relationships e1 e2)) (l2 (dbpedia-get-relationships e2 e1))) (dolist (x l1) (setf relationship-statements (cons (list e1 e2 x) relationship-statements))) (dolist (x l2) (print (list "x l2:" x)) (setf relationship-statements (cons (list e2 e1 x) relationship-statements))))))) (setf relationship-statements (remove-duplicates relationship-statements :test #'equal)) ;;(terpri message-stream) (capi::write-string-with-properties "DISCOVERED RELATIONSHIP LINKS:" '(:highlight :compiler-warning-highlight) message-stream) (terpri message-stream) (terpri message-stream) (dolist (rs relationship-statements) (format message-stream "~43A" (first rs)) (capi::write-string-with-properties sep '(:highlight :compiler-warning-highlight) message-stream) (format message-stream "~43A" (third rs)) (capi::write-string-with-properties sep '(:highlight :compiler-warning-highlight) message-stream) (format message-stream "~A" (second rs)) (terpri message-stream)) relationship-statements))
在以下 repl 列表中, 我们创建了一些与调用先前列表中的函数 get-entity-data-helper 所得到的数据形式相同的测试数据, 并使用此数据尝试调用 entity-results->relationship-links:
KGN 36 > (setf results '((:PEOPLE (("<http://dbpedia.org/resource/Bill_Gates>" "William Henry \"Bill\" Gates III (born October 28, 1955) is an American busines\ s magnate, investor, author and philanthropist. In 1975, Gates and Paul Allen co-fou\ nded Microsoft, which became the world's largest PC software company. During his car\ eer at Microsoft, Gates held the positions of chairman, CEO and chief software archi\ tect, and was the largest individual shareholder until May 2014. Gates has authored \ and co-authored several books."))) (:COMPANIES (("<http://dbpedia.org/resource/Microsoft>" "Microsoft Corporation / 02C8ma 026Akr 0259 02CCs 0252ft, -ro 028A-, - 02CCs 025\ 4 02D0ft/ (commonly referred to as Microsoft or MS) is an American multinational tec\ hnology company headquartered in Redmond, Washington, that develops, manufactures, l\ icenses, supports and sells computer software, consumer electronics and personal com\ puters and services. Its best known software products are the Microsoft Windows line\ of operating systems, Microsoft Office office suite, and Internet Explorer and Edge\ web browsers. Its flagship hardware products are the Xbox video game consoles and t\ he Microsoft Surface tablet lineup. As of 2011, it was the world's largest software \ maker by revenue, and one of the world's most valuable companies."))))) KGN 37 > (pprint (entity-results->relationship-links results)) (("<http://dbpedia.org/resource/Bill_Gates>" "<http://dbpedia.org/resource/Microsoft>" "<http://dbpedia.org/ontology/board>") ("<http://dbpedia.org/resource/Microsoft>" "<http://dbpedia.org/resource/Bill_Gates>" "<http://dbpedia.org/property/founders>") ("<http://dbpedia.org/resource/Microsoft>" "<http://dbpedia.org/resource/Bill_Gates>" "<http://dbpedia.org/ontology/keyPerson>"))
22.8. 使用 LispWorks CAPI UI 工具包
22.9. 编写 UI 实用程序
CAPI 用户界面代码位于文件 src/kgn/gui.lisp 中, 一些 UI 代码位于 options-pane.lisp 和 kgn.lisp 中.
在 KGN 应用程序底部的结果窗格中打印结果时, 我喜欢使用此函数 (kgn.lisp 中的第一个函数) 来高亮显示每个结果的第一行:
(defun pprint-results (results &key (stream t)) (dolist (result (car results)) (terpri stream) (capi::write-string-with-properties (format nil "~A:" (first result)) '(:highlight :compiler-warning-highlight) stream) (format stream " ~A~%" (second result))))
我将输入命名变量 stream 的默认值设置为 t, 因此在 repl 开发期间, 此函数的输出将转到标准输出. 在 KGN 应用程序中, 我获取底部结果窗格的用户界面输出流, 并将其作为 stream 的值传递, 因此输出直接写入结果窗格.
CAPI 允许你定义自己的文本高亮值. 我使用了内置的像 :compiler-warning-highlight 这样的值, 这些值始终可用于 CAPI 应用程序.
文件 kgn.lisp 定义了其他几个实用函数, 包括一个进行多次 SPARQL 查询以获取实体 URI 的名称和描述的实用函数, 该函数从 SPARQL 查询中移除行尾标记 “@@” 以获取实体数据, 进行查询并提取结果以供显示:
(defun get-name-and-description-for-uri (uri) (let* ((sparql (replace-all (format nil "select distinct ?name ?comment { @@ ~ values ?nameProperty {<http://www.w3.org/2000/01/rdf-schema\ #label> <http://xmlns.com/foaf/0.1/name> } . @@ ~ ~A ?nameProperty ?name . @@ ~ ~A <http://www.w3.org/2000/01/rdf-schema#comment> ?comment\ . FILTER (lang(?comment) = 'en') . @@ ~ } LIMIT 1" uri uri) "@@" " ")) (results (sparql-dbpedia sparql))) (list (second (assoc :name (car results))) (second (assoc :comment (car results))))))
文件 kgn.lisp 中还有其他几个 SPARQL 查询实用函数, 我不再讨论, 但它们遵循类似的模式, 即使用特定的 SPARQL 查询从 DBPedia 获取信息.
在文件 gui.lisp 的顶部, 我设置了应用程序窗口宽度的三个参数和一个全局标志, 用于打开和关闭你在本章开头看到的屏幕截图中看到的信息窗格图表器, 这里也显示如下:
(defvar *width * 1370) (defvar *best-width * 1020) (defvar *show-info-pane * t)
既然我刚刚提到了信息窗格图表器, 现在是深入了解其实现的好时机. 它位于不同的包中, 你可以在 src/lw-grapher/info-pane-grapher.lisp 中找到源代码. 我使用了 ISI-Grapher Manual (作者 Gabriel Robbins)68 中的图布局算法. 在 src/lw-grapher/lw-grapher.lisp 中还有另一个实用程序, 它也显示一个没有鼠标支持的图形和附加的信息窗格, 在这里没有使用, 但如果你不需要鼠标交互, 你可能更喜欢在你的项目中使用它.
图节点派生自类 capi:pinboard-object:
(defclass text-node (capi:pinboard-object) ((text :initarg :text :reader text-node-text) (string-x-offset :accessor text-node-string-x-offset) (string-y-offset :accessor text-node-string-y-offset)))
我自定义了我的图节点在图窗格中的绘制方式 (这源自 LispWorks 示例代码):
(defmethod capi:draw-pinboard-object (pinboard (self text-node) &key &allow-other-keys) (multiple-value-bind (X Y width height) (capi:static-layout-child-geometry self) (let* ((half-width (floor (1- width) 2)) (half-height (floor (1- height) 2)) (circle-x (+ X half-width)) (circle-y (+ Y half-height)) (background :white) (foreground (if background :black (capi:simple-pane-foreground pinboard))) (text (text-node-text self))) (gp:draw-ellipse pinboard circle-x circle-y half-width half-height :filled t :foreground background) (gp:draw-ellipse pinboard circle-x circle-y half-width half-height :foreground foreground) (gp:draw-string pinboard text (+ X (text-node-string-x-offset self)) (+ Y (text-node-string-y-offset self)) :foreground foreground))))
大部分工作是在使用 Gabriel Robbins 算法的图布局方法中完成的. 在这里我只显示签名, 不会深入实现. 如果你对修改布局代码感兴趣, 我在 ISI-Grapher 手册中包含了一个显示算法的单页屏幕截图, 请参阅文件 src/lw-grapher/Algorithm from ISI-Grapher Manual.png.
以下代码片段显示了文件 src/lw-grapher/grapher.lisp 中布局算法函数的方法签名. 我还包含了对 capi:graph-pane-nodes 的调用, 这是用于获取图窗格中节点对象列表的 CLOS 读取器方法:
(defun graph-layout (self &key force) (declare (ignore force)) (let* ((nodes (capi:graph-pane-nodes self)) ...
CAPI 图节点模型使用一个函数, 该函数传递一个节点对象并返回此节点的子节点对象列表. 在 src/lw-grapher/lw-grapher.lisp 中, 我编写了一个构建图布局的函数, 而不是传入一个 “返回子节点” 函数, 我发现包装这个过程更方便, 接受一个图节点列表和图边作为函数参数:
(in-package :lw-grapher) ;; A Grapher (using the layout algorithm from the ISI-Grapher ;; user guide) with an info panel (defun make-info-panel-grapher (h-root-name-list h-edge-list h-callback-function-click h-callback-function-shift-click) (let (edges roots last-selected-node node-callback-click node-callback-click-shift output-pane) (labels ((handle-mouse-click-on-pane (pane x y) (ignore-errors (let ((object (capi:pinboard-object-at-position pane x y))) (if object (let () (if last-selected-node (capi:unhighlight-pinboard-object pane last-selected-node)) (setf last-selected-node object) (capi:highlight-pinboard-object pane object) (let ((c-stream (collector-pane-stream output-pane))) (format c-stream (funcall node-callback-click (text-node-full-text object))) (terpri c-stream))))))) (handle-mouse-click-shift-on-pane (pane x y) (ignore-errors (let ((object (capi:pinboard-object-at-position pane x y))) (if object (let () (if last-selected-node (capi:unhighlight-pinboard-object pane last-selected-node)) (setf last-selected-node object) (capi:highlight-pinboard-object pane object) (let ((c-stream (collector-pane-stream output-pane))) (format c-stream (funcall node-callback-click-shift (text-node-full-text object))) (terpri c-stream))))))) (info-panel-node-children-helper (node-text) (let (ret) (dolist (e edges) (if (equal (first e) node-text) (setf ret (cons (second e) ret)))) (reverse ret))) (make-info-panel-grapher-helper (root-name-list edge-list callback-function-click callback-function-click-shift) ;; example: root-name-list: '("n1") edge-list: ;; '(("n1" "n2") ("n1" "n3")) (setf edges edge-list roots root-name-list node-callback-click callback-function-click node-callback-click-shift callback-function-click-shift) (capi:contain (make-instance 'column-layout :title "Entity Browser" :description (list (make-instance 'capi:graph-pane :min-height 330 :max-height 420 :roots roots :layout-function 'graph-layout :children-function #'info-panel-node-children-helper :edge-pane-function #'(lambda(self from to) (declare (ignore self)) (let ((prop-name "")) (dolist (edge edge-list) (if (and (equal from (first edge)) (equal to (second edge))) (if (and (> (length edge) 2) (third edge)) (let ((last-index (search "/" (third edge) :from-end t))) (if last-index (setf prop-name (subseq (third edge) (1+ last-index))) (setf prop-name (third edge))))))) (make-instance 'capi:labelled-arrow-pinboard-object :data (format nil "~A" prop-name)))) :node-pinboard-class 'text-node :input-model `(((:button-1 :release) ,#'(lambda (pane x y) (handle-mouse-click-on-pane pane x y))) ((:button-1 :release :shift) ;; :press) ,#'(lambda (pane x y) (handle-mouse-click-shift-on-pane pane x y)))) :node-pane-function 'make-text-node) (setf output-pane (make-instance 'capi:collector-pane :min-height 130 :max-height 220 :title "Message collection pane" :text "..." :vertical-scroll t :horizontal-scroll t)))) :title "Info Pane Browser: mouse click for info, mouse click + shift for web br\ owser" :best-width 550 :best-height 450))) (make-info-panel-grapher-helper h-root-name-list h-edge-list h-callback-function-click h-callback-function-shift-click))))
22.10. 编写 UI
回到文件 src/kgn/gui.lisp, 我们需要实现用于处理信息窗格面板上的鼠标单击, 显示选项弹出面板以及处理用户想要删除本地 SQLite 查询缓存时的回调的回调函数:
(defun test-callback-click (selected-node-name) (ignore-errors (format nil " * user clicked on node: ~A~%" selected-node-name))) (defun test-callback-click-shift (selected-node-name) (ignore-errors (if (equal (subseq selected-node-name 0 5) "<http") (trivial-open-browser:open-browser (subseq selected-node-name 1 (- (length selected-node-name) 1)))) (format nil " * user shift-clicked on node: ~A - OPEN WEB BROWSER~%" selected-node-name))) (defun cache-callback (&rest x) (declare (ignore x)) (if *USE-CACHING* (capi:display (make-instance 'options-panel-interface)))) (defun website-callback (&rest x) (declare (ignore x)) (trivial-open-browser:open-browser "http://www.knowledgegraphnavigator.com/"))
在第 8-10 行, 我使用第三方包 trivial-open-browser:open-browser 在你的笔记本电脑上打开默认浏览器. KGN 中的 URI 周围有尖括号字符, 所以这里我们删除这些字符. 我还在第 21-24 行使用相同的函数向用户显示我为这个示例应用程序构建的网站.
再次来自 gui.lisp, 以下列表显示了如何定义 CAPI 用户界面, 我建议你参考 CAPI 文档了解详细信息:
(capi:define-interface kgn-interface () () (:menus (action-menu "Actions" ( ("Copy generated SPARQL to clipboard" :callback #'(lambda (&rest x) (declare (ignore x)) (let ((messages (capi:editor-pane-text text-pane2))) (capi::set-clipboard text-pane2 (format nil "---- Generated SPARQL and comments:~%~%~A~%~%" messages) nil)))) ("Copy results to clipboard" :callback #'(lambda (&rest x) (declare (ignore x)) (let ((results (capi:editor-pane-text text-pane3))) (capi::set-clipboard text-pane2 (format nil "---- Results:~%~%~A~%" results) nil)))) ("Copy generated SPARQL and results to clipboard" :callback #'(lambda (&rest x) (declare (ignore x)) (let ((messages (capi:editor-pane-text text-pane2)) (results (capi:editor-pane-text text-pane3))) (capi::set-clipboard text-pane2 (format nil "---- Generated SPARQL and comments:~%~%~A~%~%---- Results:~%~%~A~%" messages results) nil)))) ("Visit Knowledge Graph Navigator Web Site" :callback 'website-callback) ("Clear query cache" :callback 'cache-callback) ((if *show-info-pane* "Stop showing Grapher window for new results" "Start showing Grapher window for new results") :callback 'toggle-grapher-visibility) ))) (:menu-bar action-menu) (:panes (text-pane1 capi:text-input-pane :text (nth (random (length *examples*)) *examples*) :title "Query" :min-height 80 :max-height 100 :max-width *width* ;;:min-width (- *width * 480) :width *best-width* :callback 'start-progress-bar-test-from-background-thread) (progress-bar capi:progress-bar :start 0 :end 100 ) (text-pane2 capi:collector-pane :font "Courier" :min-height 210 :max-height 250 :title "Generated SPARQL queries to get results" :text "Note: to answer queries, this app makes multipe SPARQL queries to DBPedia\ . These SPARQL queries will be shown here." :vertical-scroll t :create-callback #'(lambda (&rest x) (declare (ignore x)) (setf (capi:editor-pane-text text-pane2) *pane2-message*)) :max-width *width* :width *best-width* :horizontal-scroll t) (text-pane3 capi:collector-pane ;; capi:display-pane ;; capi:text-input-pane :text *pane3-message* :font "Courier" :line-wrap-marker nil :wrap-style :split-on-space :vertical-scroll :with-bar :title "Results" :horizontal-scroll t :min-height 220 :width *best-width* :create-callback #'(lambda (&rest x) (declare (ignore x)) (setf (capi:editor-pane-text text-pane3) *pane3-message*)) :max-height 240 :max-width *width*) (info capi:title-pane :text "Use natural language queries to generate SPARQL")) (:layouts (main-layout capi:grid-layout '(nil info nil text-pane1 nil text-pane2 nil text-pane3 nil progress-bar) :x-ratios '(1 99) :has-title-column-p t)) (:default-initargs :layout 'main-layout :title "Knowledge Graph Navigator" :best-width *best-width* :max-width *width*))
我之前向你展示了如何运行 KGN 示例应用程序, 我建议你在阅读用户界面代码时保持应用程序打开.
对于 KGN 的大部分开发, 代码布局和控制流都相当简单. 然而, 在应用程序完成后, 我注意到一个糟糕的用户界面问题: 多次调用 DBPedia 服务需要时间, 应用程序除了将输出流式传输到生成的 SPARQL 窗格之外, 有一段时间什么也不做, 这可能会让用户感到困惑. 我决定在主窗口底部添加一个进度条, 并将大部分查询处理功能提取到一个工作线程中, 正如以下列表所实现的, 并将一个 “更新进度条” 回调函数传递给许多创建 SPARQL 查询, 进行 Web 调用和处理结果的辅助函数. 这个回调函数移动进度条. 这种复杂性使得 KGN 代码作为书籍示例不那么好, 但使应用程序好得多. 以下函数源自一个多处理 LispWorks 示例程序. 特殊运算符 flet 中第 4-8 行定义的局部函数 update-progress-bar 是我们之前看到的函数中传递的 updater 函数. 此函数更新进度条, 并在长时间运行的函数调用期间被调用. flet 类似于 let, 但还允许定义继承 flet 中定义的任何变量的局部内容的函数.
(defun start-progress-bar-test-from-background-thread (query-text self) (with-slots (text-pane2 text-pane3 progress-bar) self (print text-pane2) (flet ((update-progress-bar (percent) (capi:execute-with-interface self #'(lambda () (setf (capi:range-slug-start progress-bar) percent))))) (mp:process-run-function "progress-bar-test-from-background-thread" '() 'run-and-monitor-progress-background-thread #'update-progress-bar query-text text-pane2 text-pane3 )))) (defvar *percent*) (defun run-and-monitor-progress-background-thread (updater text text-pane2 text-pane3) (setf *percent * 0) (unwind-protect (setf (capi:editor-pane-text text-pane2) "") (setf (capi:editor-pane-text text-pane3) "") ;;(capi:display-message "done") (let ((message-stream (collector-pane-stream text-pane2)) (results-stream (collector-pane-stream text-pane3))) (format message-stream "# Starting to process query....~%") (format results-stream *pane3-message*) (let ((user-selections (get-entity-data-helper text :updater updater :message-stream message-stream))) (setf *percent * (+ *percent * 2)) (funcall updater *percent*) (setf (capi:editor-pane-text text-pane3) "") (dolist (ev user-selections) (if (> (length (cadr ev)) 0) (let () (terpri results-stream) (capi::write-string-with-properties (format nil "- - - ENTITY TYPE: ~A - - -" (car ev)) '(:highlight :compiler-error-highlight) results-stream) (terpri results-stream) (dolist (uri (cadr ev)) (setf uri (car uri)) (case (car ev) (:people (pprint-results (dbpedia-get-person-detail uri :message-stream message-stream) :stream results-stream)) (:companies (pprint-results (dbpedia-get-company-detail uri :message-stream message-stream) :stream results-stream)) (:countries (pprint-results (dbpedia-get-country-detail uri :message-stream message-stream) :stream results-stream)) (:cities (pprint-results (dbpedia-get-city-detail uri :message-stream message-stream) :stream results-stream)) (:products (pprint-results (dbpedia-get-product-detail uri :message-stream message-stream) :stream results-stream)))))) (setf *percent * (+ *percent * 1)) (funcall updater *percent*)) (let (links x) (dolist (ev user-selections) (dolist (uri (second ev)) (setf uri (car uri)) (if (> (length ev) 2) (setf x (caddr ev))) (setf links (cons (list (symbol-name (first ev)) uri x) links)) (setf *percent * (+ *percent * 1)) (funcall updater *percent*))) (setf links (append links (entity-results->relationship-links user-selections :message-stream message-stream :updater updater))) (setf *percent * (+ *percent * 2)) (funcall updater *percent*) (if *show-info-pane* (lw-grapher:make-info-panel-grapher '("PEOPLE" "COMPANIES" "COUNTRIES" "CITIES" "PRODUCTS" "PLACES") links 'test-callback-click 'test-callback-click-shift))))) (funcall updater 0)))
我们在末尾调用回调函数 updater 以移除进度条, 让用户知道他们现在可以输入另一个查询.
如果你还没有这样做, 我希望你会花一些时间下载 LispWorks 个人版并尝试这个应用程序.
22.11. 总结
这是一个用于书籍的长示例应用程序, 所以我没有讨论项目中的所有代码. 如果你喜欢运行和试验这个例子, 并想为自己的项目修改它, 那么我希望我已经为你提供了一个足够的路线图来做到这一点.
我产生 KGN 应用程序的想法是因为我花了相当多的时间手动设置 DBPedia (以及像 WikiData 这样的其他公共来源) 的 SPARQL 查询, 我想尝试部分自动化这个过程. 我编写 CAPI 用户界面是为了好玩, 因为这个示例应用程序本可以具有与命令行工具类似的功能. 事实上, 我的第一个剪辑实现是一个命令行工具, 用户界面在文件 ui-text 中, 我们之前看过. 我决定删除命令行界面并使用 CAPI 替换它.
我所做的大部分 Common Lisp 开发都没有用户界面或实现 Web 应用程序. 当我确实需要编写带有用户界面的应用程序时, LispWorks CAPI 库使得编写用户界面相当容易.
如果你正在使用像 SBCL 或 CCL 这样的开源 Common Lisp, 并且想要添加用户界面, 那么你可能还想尝试 LTK69 和 McClim70. McClim 在 Linux 上运行良好, 在 macOS 上使用 XQuartz 也可以运行, 但字体模糊. 我也喜欢 Radiance71, 它可以生成一个 Web 浏览器, 这样你就可以将 Web 应用程序打包为桌面应用程序.
23. 书籍总结
恭喜你读完这本书! 我喜欢用简洁的代码和自下而上的方法用 Lisp 语言编程进行开发. 我希望你现在也分享这种热情. Common Lisp 有时被批评为不像 Python 和 Java 等一些较新的语言那样拥有那么多有用的库, 这是一个有效的批评. 话虽如此, 我希望本书中的各种示例能让你相信 Common Lisp 是许多类型编程项目的好选择. 我想感谢你阅读我的书, 我希望你喜欢它. 正如我在引言中提到的, 我从 1980 年代中期开始使用 Common Lisp, 并且使用其他 Lisp 方言的时间更长. 我总是在 Lisp 开发中发现一些几乎神奇的东西. 能够用宏扩展语言, 以及使用在 Lisp 中构建针对应用程序定制的迷你语言的开发技术, 使程序员能够非常高效地工作. 我通常发现这种自下而上的开发风格有助于我处理软件复杂性, 因为较低级别的函数往往在整个系统尚未完全理解而过于复杂时得到充分测试. 在开发过程的后期, 这些较低级别的函数和实用程序几乎成为编程语言的一部分, 较高级别的应用程序逻辑更容易理解, 因为你在开发过程中需要记住的代码行数更少. 我认为, 除非程序员在非常受限的应用程序领域工作, 否则成为一名多语言程序员通常是有意义的. 我尝试过, 特别是在这第四版的新材料中, 让你相信 Common Lisp 对于通用软件开发语言和作为连接不同系统的 “粘合剂” 都很好. 感谢你购买和阅读我的书! Mark Watson
Footnotes:
$ sbcl (ql:quickload "spacy") * (spacy:spacy-client "My sister has a dog Henry. She loves him.") * (defvar x (spacy:spacy-client "President Bill Clinton went to Congress. He gave a speech on taxes and Mexico.")) * (spacy:spacy-data-entities x) * (spacy:spacy-data-tokens x)
这个例子使用了 spaCy 中的深度学习 NLP 模型.
;; Misc. plotting examples using the vecto library (ql:quickload :vecto) ;; Zach Beane's plotting library (defpackage #:plotlib (:use #:cl #:vecto)) (in-package #:plotlib) ;; the coordinate (0,0) is the lower left corner of the plotting area. ;; Increasing the y coordinate is "up page" and increasing x is "to the right" ;; fills a rectangle with a gray-scale value (defun plot-fill-rect (x y width height gscale) ; 0 < gscale < 1 (set-rgb-fill gscale gscale gscale) (rectangle x y width height) (fill-path)) ;; plots a frame rectangle (defun plot-frame-rect (x y width height) (set-line-width 1) (set-rgb-fill 1 1 1) (rectangle x y width height) (stroke)) (defun plot-line(x1 y1 x2 y2) (set-line-width 1) (set-rgb-fill 0 0 0) (move-to x1 y1) (line-to x2 y2) (stroke)) (defun plot-string(x y str) (let ((font (get-font "OpenSans-Regular.ttf"))) (set-font font 15) (set-rgb-fill 0 0 0) (draw-string x y str))) (defun plot-string-bold(x y str) (let ((font (get-font "OpenSans-Bold.ttf"))) (set-font font 15) (set-rgb-fill 0 0 0) (draw-string x y str))) (defun test-plotlib (file) (with-canvas (:width 90 :height 90) (plot-fill-rect 5 10 15 30 0.2) ; black (plot-fill-rect 25 30 30 7 0.7) ; medium gray (plot-frame-rect 10 50 30 7) (plot-line 90 5 10 5) (plot-string 10 65 "test 1 2 3") (plot-string-bold 10 78 "Hello") (save-png file))) ;;(test-plotlib "test-plotlib.png")
这个绘图库在后面的章节中用于搜索, 反向传播神经网络和 Hopfield 神经网络的示例中. 我更喜欢使用特定于实现和操作系统的绘图库来生成交互式绘图, 但使用 vecto 库将绘图数据写入文件的优势在于代码可在不同操作系统和 Common Lisp 实现之间移植.
https://anaconda.org/anaconda/conda
服务器代码位于子目录 python/python_spacy_nlp_server 中, 你将在执行一次性初始化时在此处工作. 服务器安装后, 你可以从笔记本电脑上的任何目录从命令行运行它.
我建议在使用 Python 应用程序时使用虚拟 Python 环境来隔离每个应用程序或开发项目所需的依赖项. 在这里, 我假设你正在运行 Python 3.6 或更高版本. 首先你必须安装依赖项:
pip install -U spacy python -m spacy download en pip install falcon
然后切换到本书 git 仓库中的子目录 python/python_spacy_nlp_server 并安装 NLP 服务器:
cd python/python_spacy_nlp_server
python setup.py install
安装服务器后, 你可以从笔记本电脑或服务器上的任何目录运行它, 使用:
spacynlpserver
我使用 TensorFlow 或 PyTorch 编写的深度学习模型, 并提供 Python Web 服务, 这些服务可以在我用 Haskell 或 Common Lisp 编写的应用程序中使用, 使用为 Python 编写的服务编写的 Web 客户端接口. 虽然可以在 Haskell 和 Common Lisp 中直接嵌入模型, 但我发现包装我使用的深度学习模型的 REST 服务更容易且对开发人员更友好, 正如我在这里所做的那样. 深度学习模型通常只需要大约一 GB 的内存, 使用预训练模型对 CPU 资源需求很轻, 所以当我在笔记本电脑上开发时, 我可能会运行两三个模型, 并以包装的 REST 服务形式提供. 对于生产环境, 我配置 Python 服务以及我的 Haskell 和 Common Lisp 应用程序在系统启动时自动启动. 这不是一本 Python 编程书籍, 我不会讨论简单的 Python 包装代码, 但如果你也是 Python 开发人员, 你可以轻松阅读和理解代码.
https://github.com/Shirakumo/radiance 如果你在 macOS 上使用 CCL (Clojure Common Lisp), 你可以尝试支持的 COCOA-APPLICATION 包. 只有在你已经了解 Cocoa API 的情况下才建议这样做, 否则这条路线的学习曲线非常陡峭.