第二部分 · 报告一

本体(Ontology)解剖报告

一份知识层的接口协议

📌 本部分为中文精读版作者的原创分析,原书没有对应章节,因此不提供英文对照。

一直击本质

一句话:本体就是“把一个领域里的名词、以及这些名词之间的关系,写成一本书面的、机器也读得懂的规矩册”。

先说个生活直觉。

你去一家新公司报道,人事给你一份《岗位说明书》。上面写着:公司有哪些部门、部门下面有哪些岗位、每个岗位管什么、岗位之间谁向谁汇报。有了这本册子,你不用问人就知道“我要找谁签字”。

本体就是某个领域的《岗位说明书》——只不过服务的对象不只是新员工,还有软件。

正式定义(Gruber, 1993,本书第 1 章引用的那个):

本体是对某个领域中术语及其相互关系的、显式的形式化规范。
(explicit formal specifications of the terms in the domain and relations among them)

拆开这四个词:

词意思
术语这个领域里有哪些“东西”。酒、葡萄品种、酒庄、产区……
关系这些东西之间怎么挂钩。酒由酒庄生产、酒用葡萄酿、酒庄在某个产区……
显式(explicit)写下来,不是“大家心里都明白”。这一条是本体区别于“常识”的分水岭
形式化(formal)用机器能处理的语言写。这一条是本体区别于“一份 Word 文档”的分水岭

本书给的工作定义更细一点:本体 = 类 + 属性 + 约束;本体 + 一组实例 = 知识库。

二差异特征:与【面向对象编程里的类设计】的质的不同

这是本书作者自己强调的对照物,也是最容易搞混的一对——因为两边都在“设计类”,都在画类图,看起来一模一样。

原文的判词(第 1 章“About this guide”):

面向对象编程主要围绕类上的方法展开——程序员根据类的操作属性做设计决策;而本体设计者根据类的结构属性做这些决策。结果是:本体中类的结构以及类之间的关系,和一个相似领域的面向对象程序里的结构是不同的。

把这句话展开成两条质的不同:

质的不同之一:决策依据不同——“能干什么” vs “是什么”

面向对象设计本体设计
决定“要不要建这个类”的依据这个类要执行什么操作、有什么方法这个类有没有额外属性/约束/关系可说(见 4.4 节)
典型提问“这个对象需要响应哪些消息?”“关于它,我能说点什么关于它父类说不了的话?”

举个具体例子。在 OO 里,你可能会建一个 Order 类,因为订单需要有 calculateTotal()、submit() 这些方法。而同一个业务领域在本体里,Order 是不是一个类,取决于有没有关于订单的、关于订单父类说不了的话——比如“订单有一个 hasLineItem 属性,最小值 1”。如果答案是没有,本体里就不建这个类。

质的不同之二:继承的驱动因素不同——“复用代码” vs “蕴含为真”

OO 的继承是一种代码复用手段:SalaryEmployee extends Employee,图的是父类的字段和方法不用重写。OO 里用继承来复用实现,是常规操作,甚至有人批评说这会导致继承被滥用。

本体的继承是一条逻辑断言:

如果类 A 是类 B 的父类,那么 B 的每一个实例也都是 A 的实例。

这条断言必须客观上为真,而且它会驱动机器自动推理:知道某个对象是 PinotNoir,机器就能推出它必然是 RedWine,从而推出它必然有 tanninLevel 属性。不要“复用”这层意思,只有“必然如此”这层意思。

这条差异有一个非常锋利的可观察后果,就是原文 4.5 节的判据:

一个个体实例所属的类,不应该经常变。

在 OO 里,一个对象的类可以因为状态变化而换(状态模式就是这么干的);在本体里,类代表本质,实例频繁换类就说明这个“类”建错了——ChilledWine(冰镇酒)不能是类,因为一瓶酒可以今天冰着、明天不冰。

〔说人话〕一句话总结这对区别:
OO 的类是“零件”——看它要干什么活。
本体的类是“物种”——看它本质上是什么。
同一个业务,两边画出来的类图长得不一样,是正常的,不是谁错了。

再补一个更近的对照:数据库 schema。

数据库 schema本体
管理什么数据存哪、什么类型、什么约束概念是什么、概念之间什么关系
能不能表达“两个表其实是上下级分类”不能,只能靠外键模拟能,就是类层次本身
换一个领域要改什么重写整个 schema换一份本体,引擎不动(原文第 1 章“分离领域知识与操作知识”说的就是这件事)

三存在意义:它解决了什么真实痛点

痛点的名字叫“语义断层”——数据能拿到,但拼不起来;规则写在代码里,但改不动。

本书第 1 章给了五条动机,每一条对应一类具体的、会出事故的失败场景。下面把“没有它会怎样”写具体:

痛点一:三个系统说三种话,聚合不出来

场景:你想让程序去三个医疗网站抓信息,合并后回答“这个病人有没有用过某类药”。

没有本体会怎样:三个站点分别写“病人姓名 / 患者名 / name”,字段名对不上、层级对不上、枚举值对不上。程序拿到的是一堆语义无法对齐的字符串。结果不是“效率低”,而是——这个功能根本做不出来,或者退化成派人手工对照字段(每个新数据源都要重来一遍)。

有本体之后:三家共享同一套术语本体,聚合变成一行代码的事。

痛点二:知识和代码焊死,改规则要发版

场景:风控规则、“满 300 减 50”、酒菜搭配规则。这些规则本来属于业务,但被硬编码(hard-code)进了程序。

没有本体会怎样:出事故的方式非常具体——

痛点三:算法被领域绑架,换个业务就得重写

场景:你写了一个“按规格配置产品”的算法(给客户配一台定制 PC)。

没有本体会怎样:这个算法和“PC 部件”这个领域焊死了。哪天老板说“电梯也能这么配”(这是原文举的例子,出自 Rothenfluh et al., 1996),你的算法一行都用不上,得从头写一个配置电梯的。同一套逻辑,写 N 遍。

有本体之后:算法是通用引擎,把“电梯部件”的本体喂进去,同一个算法就能配电梯。写得对的话,一行代码都不用改。

痛点四:重复发明轮子

场景:每个团队做项目,都要表示“时间”这个概念。

没有本体会怎样:N 个团队写出 N 套时间模型(有人用区间、有人用时间点、有人用相对度量),互相接不上。别人已经做细的时间本体完全白费。

痛点五:本体建得对不对,没人能证明

场景:你凭直觉建了一个 500 个类的本体。

没有本体会怎样:里面的类层次有环、有等价类重复、有兄弟不在同一层级——这些你肉眼看不出来。这些错误会一路潜伏到应用里,表现为“推理结果莫名其妙”。

有本体(且是形式化的)之后:机器可以替你查——这正是第 7 章提到的 Chimaera 干的事。

🧩

① 数据拼不起来

三个系统说三种话,功能根本做不出来。

🔒

② 规则改不动

找不到、改不动、懂业务的人没有修改权。

♻️

③ 算法被绑架

配 PC 的算法配不了电梯——同一套逻辑写 N 遍。

🎡

④ 轮子反复造

N 个团队写 N 套「时间」模型,互相接不上。

🕳️

⑤ 错了没人知道

500 个类里有环、有等价重复——肉眼看不出来。

〔说人话〕把五个痛点合成一句话,本体的存在意义是:
让“知识”从“散落在代码和人脑里”变成“一份可共享、可复用、可改动、可机器检验的资产”。
它不是让程序跑得更快,而是让知识能被别的东西接住。

四流程定位

1)它处于哪个环节或接口上?

本体处在“领域知识”和“应用逻辑”之间的中间层——这是它最本质的定位。

领域知识(专家脑子里) ↓ ① 本体化:写成机器读得懂的规矩 ↓ ② 填实例 【本体】类 + 属性 + 约束 ← 结构层(骨架) ↓ ③ 被消费 【知识库】本体 + 实例 ← 数据层(血肉) 应用:推荐 / 检索 / 配置 / 问答
本体站在「领域知识」和「应用逻辑」之间的中间层。对下管概念,对上喂应用,横向当契约。

三个关键性质:

2)启动条件:什么时候必须启动它?

以下任一条件成立,就该建本体:

条件说明原文出处
多生产者/多消费者同一领域的知识要被 ≥2 个系统或团队共享第 1 章“共享共识”
假设需要显式化领域规则会变,且改的人不是程序员第 1 章“显式假设”
算法要跨领域复用同一套逻辑要服务多个领域第 1 章“分离领域与操作知识”
需要被机器批量校验数据量大到人眼盯不过来第 1 章“分析领域知识”
要与外部系统对接对方已经绑定了某个本体或受控词表第 3 章第 2 步

3)禁用条件:什么情况下绝对不该用它?

这一条最重要,本书没有集中讲,但散落在多处,这里汇总:

禁用条件一:只有一个生产者、一个消费者、而且短期内不会变。

此时本体是纯开销。原文第 1 章那句“建本体往往不是目标本身”,反面就是——如果本体不能给任何下游带来收益,它就不该存在。具体判据:如果没有第二个消费者,而且假设不会变,那么把规则写在代码里更划算。

禁用条件二:领域本身还在剧烈变动、甚至还没被理解。

第 4.1 节那个仙粉黛的例子就是警告:本体是对世界的一份静态承诺,世界一变就得返工。在领域还没定型的时候把它冻结成本体,等于给探索上了一把锁。判据:如果这个领域“上个月刚换了玩法”,先别建。先做原型,等玩法稳了再本体化。

禁用条件三:用一次就扔的探索性分析。

原文 4.7 节的原则反过来用:“你不需要比你应用所需更特化——两个方向各多一层就够了。” 如果这次分析只需要临时答案,为一个临时问题建长期本体,是拿长期资产成本干短期活。

禁用条件四:把本体当成“领域百科”来建。

原文 4.7 节明确禁止:

本体不应该包含这个领域所有可能的信息。
本体不应该包含层次中类之间所有可能的属性和区分。

原文给的实例:酒标用什么纸、虾怎么烧——不该进本体。favorite wine(最爱的酒)这种关系也不该进,哪怕“完整的表示所有互连”听起来很诱人。

✅ 该建(任一成立)

同一领域知识要被 ≥2 个系统或团队共享

领域规则会变,且改规则的人不是程序员

同一套算法要跨领域复用

数据量大到人眼盯不过来,需要机器校验

⛔ 别建(任一成立)

只有一个生产者、一个消费者,短期内不会变——纯开销

领域还在剧烈变动——给探索上了一把锁

用一次就扔的探索性分析——拿长期资产干短期活

想建成「领域百科」——酒标用什么纸、虾怎么烧,都不该进本体

〔说人话〕禁用条件的共同逻辑是:本体是一份长期契约,不是一份速记。 契约有成本(前期投入 + 后续维护 + 变更时全面返工),所以只有当收益 ≥ 成本时才签。

五操作白盒化

输入:喂给本体构建的是什么原材料?

输入来源说明
领域范围第 1 步的四个问题覆盖什么领域、干什么用、答哪些问题、谁维护
能力问题清单领域专家本体的“考卷”。是判断“建到多细就够了”的唯一硬指标
术语清单第 3 步头脑风暴一份未分类的、包含所有候选词的表——先不分类
现成本体本体库 / 外部系统第 2 步的复用检查;同时它们也是“我必须兼容”的约束
领域专家的判断人用来裁决“兄弟类在同一层级吗”“这个区分重要吗”这类机器答不了的问题

传导:内部如何处理?——七步因果链

第1步 定范围     →  产出:【边界】。决定了后面所有"要不要加"的取舍标准
     ↓
第2步 查现成     →  产出:【起点】。决定从零建还是扩展
     ↓
第3步 列术语     →  产出:【原料池】。只收集,不分类
     ↓
第4步 建类层次   →  从原料池挑出"独立存在的对象"→ 类
     ↓              用"is-a / kind-of"把类串成树
第5步 定义属性     →  原料池剩下的词 → 大多是属性
     ↓              属性挂在"能挂的最高层"
第6步 定义约束     →  给每个属性配:基数 / 值类型 / domain / range
     ↓
第7步 建实例     →  选类 → 建实例 → 填属性值
     ↓
           【本体 = 类 + 属性 + 约束】
           【知识库 = 本体 + 实例】

注意两条被原文反复强调的传导特性:

  1. 第 4 步和第 5 步是交织的,不能串行。 原文:“很难先做完其中一个再做另一个。”
  2. 整条链是迭代的、不是一次性的。 原文原则 2:“本体开发必然是一个迭代过程。”

输出:最终产出的具体是什么东西?

一个三元组加一个集合:

O = ⟨ C, S, F ⟩ ,    KB = O ∪ I
符号名字内容例子
C类集合概念 + 类层次关系{Wine, RedWine, Beaujolais, ...}
S属性集合属性位 + domain/rangemaker: Wine → Winery
F约束集合对属性取值的约束(基数、枚举、互斥)body ∈ {FULL, MEDIUM, LIGHT},基数 1
I实例集合具体的个体Chateau-Morgon-Beaujolais

这个输出的独特之处:它同时是“给机器看的”和“给人看的”。 机器拿它做推理和校验,人拿它做沟通和查证。

六代价权衡

得到 A:把领域知识变成可共享、可复用、可分析、可机器处理的一等资产。

牺牲 B:

牺牲了什么具体代价什么场景下会真的疼
前期成本建模本身不产生直接业务价值,是纯投入项目工期紧时,本体最容易被砍——砍掉之后一切都写进代码里,第 1 章那四个痛点全数复发
变更刚度本体是对世界的一份承诺。世界变了,本体必须跟着改,而本体常常已被多个下游依赖仙粉黛场景(原文 4.1 节):“所有仙粉黛都是红酒”当年是真的,后来出现了白仙粉黛,整个类的定位就得拆。而此刻可能已经有 5 个下游在用 Zinfandel ⊂ RedWine 这个断言
表达力 ↔ 推理成本语言表达力越强(能写更复杂的约束),机器推理越贵;全于一阶逻辑的推理甚至是不可判定的本体大到几千个类、约束互相咬合时,推理机会跑不动或算不完
显式化会逼你现在就做决定写下来就意味着“放弃模糊”。而领域专家往往靠“看情况”工作遇到“这条规则通常是这样,但有例外”时,本体不接受“通常”。你被迫要么写死、要么把例外也建出来——两条路都有代价

最疼的那一刀,是“变更刚度”和“显式化逼你做决定”的叠加。 原文 4.7 节那个 Experimenter 的例子就是现场:

把 Experimenter 建成 BiologicalOrganism 的子类,逻辑上为真(实验者确实是人,确实是生物有机体)。但这么一建,所有实验者实例就被塞进了体重、年龄、物种这些与实验场景完全无关的属性。

更要命的是,这个决定会误导后来的复用者:他看到 Experimenter 不是 Person 的子类,会以为是漏了,然后“顺手补上”——把你当初的设计意图彻底搞乱。

所以原文给的处方是:该省的关系就省,但省下的原因必须写进文档。

〔说人话〕这笔交易的实质:
你付出“前期投入 + 后期返工”的代价,买回“知识可以被别的系统接住”的能力。
买不买,取决于知识会被接住几次。只接一次,别买;要接很多次、接很多年,必须买。

七生态对接

上游:谁给它喂材料?

上游喂什么原文出处
领域专家能力问题清单、术语清单、对“这两个概念是不是一回事”的裁决第 1 步、第 3 步
现成本体库可复用的本体(Ontolingua、DAML 库、UNSPSC、RosettaNet、DMOZ)第 3 章第 2 步
外部系统的既有本体/受控词表硬约束——不是可选项,是兼容性要求第 3 章第 2 步
形式化语言承载本体的语言本身:RDF、DAML、Ontolingua、CLASSIC 方言第 1 章、第 3 章第 2 步
知识表示系统的能力决定命名约定能不能这么定(名字空间、大小写敏感性、分隔符)第 6 章

下游:它产出的东西喂给了谁?

下游接过去干什么原文出处
具体应用酒菜搭配推荐、回答服务员和顾客的提问、分析酒窖库存并建议采购第 1 章末
知识库本体 + 实例 = 知识库,供问题求解程序消费第 2 章
跨领域算法通用配置引擎把本体当“可插拔知识包”——配 PC、配电梯,同一个算法第 1 章
其他本体被复用、被扩展。通用本体(如 UNSPSC)被扩展出具体领域版本第 1 章、第 3 章第 2 步
分析工具Chimaera 这类工具对演进中的本体跑诊断:查逻辑正确性、查常见设计错误第 7 章
智能体多站点信息抽取与聚合,回答用户查询,或作为其他程序的输入数据第 1 章
人命名约定、文档、同义词表——供新用户理解领域术语第 1 章、第 6 章

这个生态里有一个反馈环,是本书最看重的一件事:

本体 → 应用 → 用起来的效果 → 回头修本体

原文结论章说得很直白:“布丁好不好,吃了才知道”——我们只能通过在实际应用中使用本体,来评估它的质量。

八旧知缝合

用你脑子里已有的东西给本体找个位置。

比方一(最好用的一个):本体之于知识,就像“接口协议”之于系统

你写过或者用过 API 吧?两个系统要对话,得先约定好:接口叫什么名、传什么参数、返回什么格式。这份约定就是协议。

本体就是“知识层的接口协议”。

这个比方的三个延伸都很准:

协议的性质本体的对应性质
协议不关心你怎么实现本体不关心应用怎么算
协议一旦发布就很难改(有兼容性负担)本体一旦被多个下游用上就很难改(4.1 节仙粉黛场景)
协议版本升级需要“迁移”本体演化需要对下游做迁移(4.1 节)
内部小项目不需要协议禁用条件一:只有一个生产者一个消费者时不建本体

比方二:像图书馆的分类法

这个比方能帮你理解 4.6 节那个产区例子:“阿尔萨斯”不是“法国”的一种,但“阿尔萨斯产区”是“法国产区”的一种。 就像 TP311 是 TP3 的一种分类,而不是“巴黎是法国的一种”。

比方三:像 Excel 和数据库表的区别——但比它更强

你会用 Excel 记账,但你也知道 Excel 管不住“这一列只能填红/白/桃红”。数据库能管(加约束)。

那本体比数据库多了什么?多了“分类”这件事本身。

数据库里,“红酒”和“白葡萄酒”只是某一列的两个值,数据库不知道“红酒”和“白酒”是互斥的、“红酒”和“甜酒”可以重叠。本体知道——这就是 4.8 节的互斥声明,和 4.3 节的多重继承。有了这两样,机器才能替你查错。

🔌

API 协议 ↔ 本体

本体就是知识层的接口协议:两边独立开发,只要协议不变就永远能接上。

📚

图书馆分类法 ↔ 本体

类 = 分类号,层次 = 上下级,实例 = 具体一本书,属性 = 书脊字段。

📊

数据库 ↔ 本体

数据库不知道「红与白互斥」。本体知道——多了「分类」这件事本身。

本体 = 一份知识层的接口协议 + 一张可推理的分类法
它的价值不在于「记录下来」,而在于「让别的东西能接住」

九数值案例串讲

下面用一个能手工验算的小本体,把八步走一遍。案例来自本书的酒本体,但只取 10 个类、7 个属性——小到可以口算,大到能体现机制。

9.1 场景与数据

领域范围(第 1 步):酒与食物搭配推荐。能力问题(第 1 步):5 道题(下面 9.5 会用来算覆盖率)。

类与直接父类(第 4 步的产出):

类直接父类
Wine(无,顶层类)
WhiteWineWine
RedWineWine
DessertWineWine
BeaujolaisRedWine
BordeauxRedWine
MedocBordeaux
PauillacMedoc
MargauxMedoc
PortRedWine, DessertWine ← 多重继承(4.3 节)

属性(第 5、6 步的产出):

属性domain(挂在哪个类上)range / 约束
colorWine{RED, WHITE, ROSE},单基数
bodyWine{FULL, MEDIUM, LIGHT},单基数
makerWineInstance,允许类 Winery,单基数
grapeWineInstance,允许类 WineGrape,最小基数 1
tanninLevelRedWine{LOW, MODERATE, HIGH},单基数
sugarDessertWine定值 SWEET
producesWineryInstance,待定的 range ← 9.4 要化简它

9.2 公式

公式 1:祖先的传递闭包(对应第 4 步“层次是可传递的”)

Anc(c) = Super(c) ∪ ⋃p ∈ Super(c) Anc(p)
(c₀ 无父类时,Anc(c₀) = ∅)

公式 2:继承的属性(对应第 5 步“子类继承父类的属性”)

Slots(c) = Local(c) ∪ ⋃p ∈ Super(c) Slots(p)
注意公式 2 用 ∪(并集)而不是加法——因为多重继承时,两个父类可能有同名的属性(如 Port 从 RedWine 和 DessertWine 都继承到 color),并集保证只算一次。

公式 3:子类数量守卫(对应第 4.2 节的两条准则)

Alert(c) = [ |DirectSub(c)| = 1 ] ∨ [ |DirectSub(c)| > 12 ]

输出 1 表示需要人工复核(不是“必定错了”,是“去看一眼”)。

公式 4:domain / range 化简(对应第 6 步的三条规则,规则一的形式化)

D' = { x ∈ D | ¬∃ y ∈ D, y ≠ x, y ∈ Anc(x) }

读法:把所有“祖上也在这个集合里”的元素删掉——只留最一般的那个。

公式 5:能力问题覆盖率(对应第 1 步的“考卷”)

Cov(O) = |{ q ∈ Q : Answerable(q, O) = 1 }| / |Q|

取值 [0,1],越高越好。这个数字直接指导“本体还需要补什么”。

9.3 手工验算

① 祖先闭包(公式 1)

类Anc个数
Wine∅0
WhiteWine{Wine}1
RedWine{Wine}1
Beaujolais{RedWine, Wine}2
Bordeaux{RedWine, Wine}2
Medoc{Bordeaux, RedWine, Wine}3
Pauillac{Medoc, Bordeaux, RedWine, Wine}4
DessertWine{Wine}1
Port{RedWine, DessertWine, Wine}3
口算验算:Pauillac 往上数——Medoc(1) → Bordeaux(2) → RedWine(3) → Wine(4)。四层,对上了。

多重继承的验算:Port 有两个父类,但 Anc 只有 3 个而不是 4 个——因为 Wine 被两条路都走到,集合自动去重。这就是公式 1 用 ∪ 不用 + 的原因。

② 继承的属性(公式 2)

Local:Wine 加 {color, body, maker, grape};RedWine 加 {tanninLevel};DessertWine 加 {sugar};其余类不加。

类计算|Slots|
Wine{color, body, maker, grape}4
RedWine上面 ∪ {tanninLevel}5
DessertWine上面 ∪ {sugar}5
Pauillac同 Medoc 同 Bordeaux 同 RedWine5
PortSlots(RedWine) ∪ Slots(DessertWine)6
验算多重继承:5 + 5 = 10,但结果只有 6——因为 4 个属性(color、body、maker、grape)被两条父类路径共享,所以 10 − 4 = 6。“是 5+5 还是 6”,这就是 ∪ 和 + 的区别。

③ 子类数量守卫(公式 3,阈值:=1 或 >12 报警)

类DirectSub个数Alert
Wine{WhiteWine, RedWine, DessertWine}30
RedWine{Beaujolais, Bordeaux, Port}30
DessertWine{Port}11 ⚠️
Bordeaux{Medoc}11 ⚠️
Medoc{Pauillac, Margaux}20
⚠️ 两处命中原文 4.2 节的警告(跑代码时第二处才浮出来,手工列表里差点漏掉——这正好说明为什么需要机器守卫)。这不是“必定错了”,而是两个需要复核的信号,而且两条的复核结论方向相反:

Bordeaux(1 个子类:Medoc)——两种可能:(1) 如果 Medoc 和 Bordeaux 其实等价 → 删掉(原文图 6a 的情形);(2) 波尔多底下本还该有别的子类(如 St.Emilion、Graves)→ 本体不完整,继续补。

DessertWine(1 个子类:Port)——这个信号几乎是纯的“本体不完整”。现实中的甜酒远不止波特一种(原文图 7b 里就列了 Sauterne、Sweet Riesling、Ice Wine)。

两个警告同形不同因,这就是为什么这条守卫只能报警、不能下判决。 它把你拽回来做复核——复核的结论由人给,不由规则给。

④ range 化简(公式 4)

produces 属性的值域,初版由某个开发者写成 D = {Wine, RedWine, WhiteWine}。逐个检查是否有“祖上也在 D 里”:

xAnc(x)祖上在 D 里吗?判决
Wine∅否保留
RedWine{Wine}是删除
WhiteWine{Wine}是删除
D' = {Wine},   条目删减率 = (3−1)/3 = 66.7%
这 2 条被删掉的条目,就是原文 4.6 节吐属性的“重复劳动”。 更重要的不是省了 2 行,而是——没有这两行,以后就不会有人改漏。 假设某天要给 RoseWine 也加进来:D={Wine} 的版本不用改(RoseWine 自动被覆盖),而 D={Wine, RedWine, WhiteWine} 的版本得记得再加一行。化简不是美观问题,是维护成本问题。

⑤ 能力问题覆盖率(公式 5)

#能力问题答得出来吗?依据
q1波尔多是红酒还是白酒?✅ 1Bordeaux 继承 color 属性
q2赤霞珠配海鲜吗?❌ 0本体里没有 pairing 关系——缺一块
q3甜酒的糖分是什么?✅ 1DessertWine.sugar = SWEET(定值)
q4哪家酒庄生产 Sterling Merlot?✅ 1maker / produces 逆属性对(5.1 节)
q5纳帕仙粉黛哪几年份好?❌ 0本体里没有 vintage 属性——缺一块
Cov(O) = 3/5 = 0.6
这个 0.6 就是下一步的行动清单。 q2 和 q5 答不出来,明确告诉我们两件事:要么补充本体(加 pairing 和 vintage),要么接受“这个本体不服务这类问题”,并在文档里写清楚(原文 4.7 节的要求)。
不做这一步,你永远不知道自己建到什么程度了。

9.4 Java 代码

下面这段代码把上面的公式全部实现,可以直接编译运行(Java 17+)。

import java.util.*;

/**
 * 本体审计器:实现本报告 9.2 节的五条公式
 * 对应知识解剖八步:公式1/2 属第五步(白盒化),公式3/4/5 属第四步(流程定位)
 */
public class OntologyAudit {

    // ---------- 输入:类 -> 直接父类(第四步 建类层次的产出)----------
    static final Map<String, List<String>> SUPER = new LinkedHashMap<>();
    static {
        SUPER.put("Wine",        List.of());                            // 顶层类
        SUPER.put("WhiteWine",   List.of("Wine"));
        SUPER.put("RedWine",     List.of("Wine"));
        SUPER.put("DessertWine", List.of("Wine"));
        SUPER.put("Beaujolais",  List.of("RedWine"));
        SUPER.put("Bordeaux",    List.of("RedWine"));
        SUPER.put("Medoc",       List.of("Bordeaux"));
        SUPER.put("Pauillac",    List.of("Medoc"));
        SUPER.put("Margaux",     List.of("Medoc"));
        SUPER.put("Port",        List.of("RedWine", "DessertWine"));     // 多重继承(4.3 节)
    }

    // ---------- 公式1:祖先的传递闭包 ----------
    // Anc(c) = Super(c) ∪ ⋃_{p ∈ Super(c)} Anc(p)
    // path 用来检测类环(4.1 节"避免类环")——有环就会无限递归,这里让它显式报错
    static final Map<String, Set<String>> ANC = new HashMap<>();

    static Set<String> ancestors(String c, Deque<String> path) {
        if (path.contains(c)) {                                          // 出现环 = 本体已损坏
            throw new IllegalStateException("类层次里有环: " + path + " -> " + c);
        }
        Set<String> cached = ANC.get(c);
        if (cached != null) return cached;                               // 记忆化:同一个类只算一次

        path.push(c);                                                    // 进入本层
        Set<String> acc = new LinkedHashSet<>();
        for (String p : SUPER.getOrDefault(c, List.of())) {              // 遍历直接父类
            acc.add(p);                                                  // 收下直接父类
            acc.addAll(ancestors(p, path));                              // 递归收下父类的父类
        }
        path.pop();                                                      // 离开本层
        ANC.put(c, acc);
        return acc;
    }

    static Set<String> ancestors(String c) { return ancestors(c, new ArrayDeque<>()); }

    // ---------- 公式2:继承的属性 ----------
    // Slots(c) = Local(c) ∪ ⋃_{p ∈ Super(c)} Slots(p)   —— 注意是并集,多重继承不会重复计数
    static final Map<String, Set<String>> LOCAL = new LinkedHashMap<>();
    static {
        LOCAL.put("Wine",        new LinkedHashSet<>(List.of("color", "body", "maker", "grape")));
        LOCAL.put("RedWine",     new LinkedHashSet<>(List.of("tanninLevel")));
        LOCAL.put("DessertWine", new LinkedHashSet<>(List.of("sugar")));
    }

    static Set<String> slots(String c) {
        Set<String> acc = new LinkedHashSet<>(LOCAL.getOrDefault(c, Set.of()));
        for (String p : SUPER.getOrDefault(c, List.of())) {
            acc.addAll(slots(p));                                        // 父类的属性往下传
        }
        return acc;
    }

    // ---------- 公式3:子类数量守卫 ----------
    // Alert(c) = [|DirectSub(c)| = 1] ∨ [|DirectSub(c)| > 12]
    static List<String> directSubs(String c) {
        List<String> out = new ArrayList<>();
        for (var e : SUPER.entrySet()) {
            if (e.getValue().contains(c)) out.add(e.getKey());
        }
        return out;
    }

    static boolean alert(String c) {
        int n = directSubs(c).size();
        return n == 1 || n > 12;                                         // 只报警,不下判决——需要人复核
    }

    // ---------- 公式4:domain / range 化简 ----------
    // D' = { x ∈ D | ¬∃y ∈ D, y≠x, y ∈ Anc(x) }
    static Set<String> simplify(Set<String> d) {
        Set<String> out = new LinkedHashSet<>();
        for (String x : d) {
            boolean ancestorAlsoInD = ancestors(x).stream()
                                       .anyMatch(y -> !y.equals(x) && d.contains(y));
            if (!ancestorAlsoInD) out.add(x);                            // 祖上也在集合里 → 冗余 → 删
        }
        return out;
    }

    // ---------- 公式5:能力问题覆盖率 ----------
    static double coverage(List<String> questions, Set<String> answerable) {
        long hit = questions.stream().filter(answerable::contains).count();
        return (double) hit / questions.size();
    }

    public static void main(String[] args) {
        System.out.println("=== 公式1:祖先闭包 ===");
        for (String c : SUPER.keySet()) {
            System.out.printf("  Anc(%-12s) = %-45s |Anc|=%d%n",
                    c, ancestors(c), ancestors(c).size());
        }

        System.out.println("\n=== 公式2:继承的属性(含多重继承)===");
        for (String c : List.of("Wine", "RedWine", "Pauillac", "Port")) {
            System.out.printf("  Slots(%-10s) = %-58s |Slots|=%d%n",
                    c, slots(c), slots(c).size());
        }

        System.out.println("\n=== 公式3:子类数量守卫(=1 或 >12 报警)===");
        for (String c : SUPER.keySet()) {
            if (alert(c)) System.out.printf("  ⚠️  %s 只有/超过异常数量的直接子类: %s%n", c, directSubs(c));
        }

        System.out.println("\n=== 公式4:range 化简 ===");
        Set<String> raw = new LinkedHashSet<>(List.of("Wine", "RedWine", "WhiteWine"));
        System.out.println("  原始 range : " + raw);
        System.out.println("  化简后     : " + simplify(raw));

        System.out.println("\n=== 公式5:能力问题覆盖率 ===");
        List<String> qs = List.of("q1:波尔多是红酒还是白酒", "q2:赤霞珠配海鲜吗",
                                  "q3:甜酒的糖分是什么",   "q4:哪家酒庄生产SterlingMerlot",
                                  "q5:纳帕仙粉黛哪几年份好");
        Set<String> answerable = Set.of(qs.get(0), qs.get(2), qs.get(3));
        System.out.printf("  Cov(O) = %.2f  (答不出:%s)%n", coverage(qs, answerable),
                          qs.stream().filter(q -> !answerable.contains(q)).toList());
    }
}

运行结果(已在本机 OpenJDK 17 上实际执行,与 9.3 手工验算一致):

=== 公式1:祖先闭包 ===
  Anc(Wine        ) = []                                            |Anc|=0
  Anc(WhiteWine   ) = [Wine]                                        |Anc|=1
  Anc(RedWine     ) = [Wine]                                        |Anc|=1
  Anc(DessertWine ) = [Wine]                                        |Anc|=1
  Anc(Beaujolais  ) = [RedWine, Wine]                               |Anc|=2
  Anc(Bordeaux    ) = [RedWine, Wine]                               |Anc|=2
  Anc(Medoc       ) = [Bordeaux, RedWine, Wine]                     |Anc|=3
  Anc(Pauillac    ) = [Medoc, Bordeaux, RedWine, Wine]              |Anc|=4
  Anc(Margaux     ) = [Medoc, Bordeaux, RedWine, Wine]              |Anc|=4
  Anc(Port        ) = [RedWine, Wine, DessertWine]                  |Anc|=3

=== 公式2:继承的属性(含多重继承)===
  Slots(Wine      ) = [color, body, maker, grape]                                |Slots|=4
  Slots(RedWine   ) = [tanninLevel, color, body, maker, grape]                   |Slots|=5
  Slots(Pauillac  ) = [tanninLevel, color, body, maker, grape]                   |Slots|=5
  Slots(Port      ) = [tanninLevel, color, body, maker, grape, sugar]            |Slots|=6

=== 公式3:子类数量守卫(=1 或 >12 报警)===
  ⚠️  DessertWine 只有/超过异常数量的直接子类: [Port]
  ⚠️  Bordeaux 只有/超过异常数量的直接子类: [Medoc]

=== 公式4:range 化简 ===
  原始 range : [Wine, RedWine, WhiteWine]
  化简后     : [Wine]

=== 公式5:能力问题覆盖率 ===
  Cov(O) = 0.60  (答不出:[q2:赤霞珠配海鲜吗, q5:纳帕仙粉黛哪几年份好])

9.5 逐行对照八步

代码块对应哪一步为什么
SUPER 表第五步·输入这是第 4 步“建类层次”的产出,喂给审计器
ancestors() 的递归 + 记忆化第五步·传导实现公式 1。记忆化对应“本体是大图,同一个祖先会被重复问到”
ancestors() 里的环检测抛异常第四步·禁用条件显式对应 4.1 节“避免类环”——有环在数学上就无解,必须硬失败
LOCAL 表只给 RedWine 加 tanninLevel第六步·代价权衡“挂在能挂的最高层”的落地。挂 Wine 会污染白酒
slots() 用 addAll 而非累加第五步·传导∪ 而非 +,保证多重继承不重复计数。Port 是 6 而不是 10
alert() 的 n == 1 || n > 12第四步·启动条件4.2 节两条守卫规则的直接编码。注意它只报警不下判决
simplify() 的 anyMatch第六步·代价权衡公式 4 落地。对应原文“不要在 range 里同时列父类和子类”
coverage()第一步·本质 + 第四步·流程定位用能力问题反查本体完整度——“本体够不够用”的唯一客观指标
两条 ⚠️ 告警第八步·旧知缝合把“项目符号列表不该只有一个条目”这个排版直觉,变成了可执行检查
← 上一章 下一章:报告二 · 三分判定 →