Skip to content

Commit fe83281

Browse files
committed
Add multi-valued discrete Bayes nets + BIF loader; ship the Insurance net (#1285)
AIMA 4e (§16) references a discrete car-insurance Bayesian network whose CPTs 'are given in the code repository', but they were never in aima-python, and the existing BayesNet/BayesNode are boolean-only — so the multi-valued Insurance model could not be represented. Adds: - DiscreteBayesNode / DiscreteBayesNet in probability.py: nodes whose variables have arbitrary finite domains. They expose the same node.p(value, event) / variable_values interface the inference already uses, so enumeration_ask AND elimination_ask work on them unchanged (verified: both match a hand-computed posterior; the boolean BayesNet is untouched). - read_bif(): parses the Bayesian Interchange Format used by the bnlearn Bayesian Network Repository into a DiscreteBayesNet. - insurance(): loads the 27-variable Insurance network (Binder, Koller, Russell & Kanazawa 1997) from aima-data/insurance.bif (added to aima-data, submodule bumped). Exact inference runs on the full network (e.g. P(Age)=[.2,.6,.2]). Tests: small multi-valued net (enumeration == elimination == expected), read_bif parsing, and the Insurance net (loads, 27 nodes, exact query). 34 passed.
1 parent 2d966e6 commit fe83281

3 files changed

Lines changed: 177 additions & 1 deletion

File tree

aima-data

aima/probability.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Probability models (Chapter 13-15)"""
22

33
import copy
4+
import re
45
from collections import defaultdict
56
from functools import reduce
67

@@ -409,6 +410,136 @@ def __repr__(self):
409410
return repr((self.variable, ' '.join(self.parents)))
410411

411412

413+
class DiscreteBayesNode:
414+
"""A node of a discrete Bayesian network whose variable may take more than two
415+
values (unlike :class:`BayesNode`, which is boolean).
416+
417+
``values`` is the variable's domain. ``cpt`` maps each tuple of parent values
418+
(ordered as in ``parents``) to the probabilities over ``values`` -- either a
419+
sequence in domain order or a ``{value: prob}`` dict. A root node uses the
420+
empty tuple ``()`` as its only key.
421+
"""
422+
423+
def __init__(self, X, parents, values, cpt):
424+
if isinstance(parents, str):
425+
parents = parents.split()
426+
self.variable = X
427+
self.parents = parents
428+
self.values = list(values)
429+
self.cpt = {}
430+
for key, dist in cpt.items():
431+
key = key if isinstance(key, tuple) else (key,)
432+
assert len(key) == len(self.parents)
433+
self.cpt[key] = dist if isinstance(dist, dict) else dict(zip(self.values, dist))
434+
self.children = []
435+
436+
def p(self, value, event):
437+
"""Return P(X = ``value`` | parents = their values in ``event``)."""
438+
return self.cpt[tuple(event[parent] for parent in self.parents)][value]
439+
440+
def __repr__(self):
441+
return repr((self.variable, ' '.join(self.parents)))
442+
443+
444+
class DiscreteBayesNet:
445+
"""A Bayesian network of :class:`DiscreteBayesNode`\\ s (variables with arbitrary
446+
finite domains). Exact inference works through the generic
447+
:func:`enumeration_ask` / :func:`elimination_ask`, which rely only on
448+
``node.p`` and ``variable_values`` and so work unchanged for multi-valued nodes.
449+
"""
450+
451+
def __init__(self, node_specs=None):
452+
self.nodes = []
453+
self.variables = []
454+
for spec in node_specs or []:
455+
self.add(spec)
456+
457+
def add(self, node_spec):
458+
"""Add a ``DiscreteBayesNode`` (or a ``(name, parents, values, cpt)`` spec);
459+
its parents must already be in the net and its variable must not."""
460+
node = node_spec if isinstance(node_spec, DiscreteBayesNode) else DiscreteBayesNode(*node_spec)
461+
assert node.variable not in self.variables
462+
assert all(parent in self.variables for parent in node.parents)
463+
self.nodes.append(node)
464+
self.variables.append(node.variable)
465+
for parent in node.parents:
466+
self.variable_node(parent).children.append(node)
467+
468+
def variable_node(self, var):
469+
for n in self.nodes:
470+
if n.variable == var:
471+
return n
472+
raise Exception("No such variable: {}".format(var))
473+
474+
def variable_values(self, var):
475+
"""Return the domain of var."""
476+
return self.variable_node(var).values
477+
478+
def __repr__(self):
479+
return 'DiscreteBayesNet({0!r})'.format(self.nodes)
480+
481+
482+
def read_bif(source):
483+
"""Parse a Bayesian network in BIF (Bayesian Interchange Format) into a
484+
:class:`DiscreteBayesNet`. ``source`` is the BIF text or an open file object.
485+
486+
BIF is the format used by the Bayesian Network Repository
487+
(https://www.bnlearn.com/bnrepository/), so this lets aima load standard
488+
multi-valued networks such as the car-insurance ("Insurance") model.
489+
"""
490+
text = source.read() if hasattr(source, 'read') else source
491+
492+
# variable NAME { type discrete [ k ] { v1, v2, ... }; }
493+
domains = {name: [v.strip() for v in vals.split(',') if v.strip()]
494+
for name, vals in re.findall(r'variable\s+(\w+)\s*\{[^}]*?\{([^}]*)\}', text)}
495+
496+
# probability ( VAR [ | P1, P2, ... ] ) { table ... ; | (pv, ...) p, ... ; }
497+
specs = {}
498+
for header, body in re.findall(r'probability\s*\(\s*([^)]*?)\s*\)\s*\{(.*?)\}', text, re.S):
499+
if '|' in header:
500+
var, parent_str = header.split('|')
501+
var, parents = var.strip(), [p.strip() for p in parent_str.split(',') if p.strip()]
502+
else:
503+
var, parents = header.strip(), []
504+
cpt = {}
505+
table = re.search(r'table\s+([^;]+);', body)
506+
if table:
507+
cpt[()] = [float(x) for x in table.group(1).split(',')]
508+
else:
509+
for key, probs in re.findall(r'\(([^)]*)\)\s*([^;]+);', body):
510+
cpt[tuple(v.strip() for v in key.split(',') if v.strip())] = \
511+
[float(x) for x in probs.split(',')]
512+
specs[var] = (parents, domains[var], cpt)
513+
514+
# add nodes parents-before-children (the BIF order need not be topological)
515+
net, added = DiscreteBayesNet(), set()
516+
517+
def add_node(name):
518+
if name in added:
519+
return
520+
parents, values, cpt = specs[name]
521+
for parent in parents:
522+
add_node(parent)
523+
net.add(DiscreteBayesNode(name, parents, values, cpt))
524+
added.add(name)
525+
526+
for name in specs:
527+
add_node(name)
528+
return net
529+
530+
531+
def insurance():
532+
"""Return the car-insurance ("Insurance") Bayesian network as a
533+
:class:`DiscreteBayesNet`, loaded from ``aima-data/insurance.bif``.
534+
535+
This is the 27-variable discrete model of Binder, Koller, Russell & Kanazawa
536+
(1997) referenced by the AIMA 4e car-insurance case study (Section 16); the
537+
book notes the discrete conditional distributions are provided in the code
538+
repository, and this is them.
539+
"""
540+
return read_bif(open_data('insurance.bif').read())
541+
542+
412543
# Burglary example [Figure 14.2]
413544

414545
T, F = True, False

tests/test_probability.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,5 +556,50 @@ def test_gibbs_ask():
556556
True
557557
"""
558558

559+
560+
def test_discrete_bayes_net_inference():
561+
# a small multi-valued net; both exact-inference engines must agree with the
562+
# hand-computed posterior P(Rain | Traffic=high)
563+
net = DiscreteBayesNet([
564+
('Rain', '', ['none', 'light', 'heavy'], {(): [0.6, 0.3, 0.1]}),
565+
('Traffic', 'Rain', ['low', 'high'],
566+
{('none',): [0.9, 0.1], ('light',): [0.6, 0.4], ('heavy',): [0.2, 0.8]}),
567+
])
568+
expected = {'none': 0.06 / 0.26, 'light': 0.12 / 0.26, 'heavy': 0.08 / 0.26}
569+
for ask in (enumeration_ask, elimination_ask):
570+
q = ask('Rain', {'Traffic': 'high'}, net)
571+
for value, p in expected.items():
572+
assert abs(q[value] - p) < 1e-9
573+
574+
575+
def test_read_bif():
576+
bif = """
577+
network n { }
578+
variable A { type discrete [ 2 ] { yes, no }; }
579+
variable B { type discrete [ 3 ] { lo, mid, hi }; }
580+
probability ( A ) { table 0.3, 0.7; }
581+
probability ( B | A ) {
582+
(yes) 0.2, 0.3, 0.5;
583+
(no) 0.1, 0.1, 0.8;
584+
}
585+
"""
586+
net = read_bif(bif)
587+
assert set(net.variables) == {'A', 'B'}
588+
assert net.variable_values('B') == ['lo', 'mid', 'hi']
589+
assert net.variable_node('B').parents == ['A']
590+
assert net.variable_node('A').p('yes', {}) == 0.3
591+
assert net.variable_node('B').p('hi', {'A': 'no'}) == 0.8
592+
593+
594+
def test_insurance_bayes_net():
595+
# the car-insurance ("Insurance") network loads from aima-data and supports
596+
# exact inference (AIMA 4e §16 case study, issue #1285)
597+
net = insurance()
598+
assert len(net.variables) == 27
599+
assert net.variable_values('Age') == ['Adolescent', 'Adult', 'Senior']
600+
age = elimination_ask('Age', {}, net)
601+
assert (round(age['Adolescent'], 3), round(age['Adult'], 3), round(age['Senior'], 3)) == (0.2, 0.6, 0.2)
602+
603+
559604
if __name__ == '__main__':
560605
pytest.main()

0 commit comments

Comments
 (0)