February 24 - Reasoning under Uncertainty

AIMA Chapter 13

In [1]:
from probability import *
from utils import print_table
from notebook import psource, pseudocode, heatmap

Failures of Logic

First-order logic represents a certainty

  • ∀x Symptom(x,Toothache) ⇒ Disease(x,Cavity)

To make the rule true, we must add an almost unlimited set of causes

  • ∀x Symptom(x,Toothache) ⇒ Disease(x,Cavity) ∨ Disease(x,GumDisease) ∨ Disease(x,Abscess) ∨ ...

Conversion to a causal rule does not help

  • ∀x Disease(x,Cavity) ⇒ Symptom(x,Toothache)

Not all cavities cause pain

Why does Logic Fail?

Laziness

  • Too much work to list entire sets of consequents or antecedents.
  • Cannot list all possible causes for a toothache.

Theoretical Ignorance

  • No complete theory for domain exists (pace blocks world, versus medicine).
  • Cannot describe precisely the conditions that cause cancer.

Practical Ignorance

  • Even if we know all the rules, we may be uncertain about a particular event.
  • What was the white blood cell count of the patient two years ago?

Probability provides a way of summarizing the uncertainty that comes from our laziness and ignorance.

We can say that it will probably take 2 hours to drive to the airport, without listing all possible events that might intervene.

Basic Probability I

We assume you have some basic understanding of probability. For example, by now, from homework 3, you should know that the only way to win consistently at blackjack is to own a casino.

Here is quick review.

A probability is a defined numerically as a real number between 0 and 1, inclusive.

Unconditional (prior) probability:

  • P(Cavity) = 0.1

Random variable - a variable that represents a probability of an event, e.g., Weather

  • P(Weather = snow) = 0.05

Probability Distribution

Examples of Probability Distributions:

Conditional (posterior) probability: (Note: "|" here is pronounced "given", not "or")

  • P(Cavity | Toothache) = 0.8

That means, whenever toothache is true, and we have no further information, conclude that cavity is it true with probability 0.8.

Similarly, we have

  • P(Cavity | Dentures) = 0.0

Basic Probability II

Begin with a set $\Omega$ — the sample space, e.g., 6 possible rolls of a die.

$\omega{} \in{} \Omega$ is a sample point/possible world/atomic event, e.g., a roll of the die.

A probability space or probability model is a sample space with an assignment $P(\omega)$ for every $\omega ∈ \Omega$ such that $0 ≤ P(\omega) ≤ 1$ and

$$\sum\nolimits_{\omega}P(\omega) = 1$$

e.g., for a die, P(1) = P(2) = P(3) = P(4) = P(5) = P(6) = 1/6

Axioms

$$0 \leq P(A) \leq 1$$

$$P(True) = 1$$

$$P(False) = 0$$

$$P(A ∨ B) = P(A) + P(B) - P(A ∧ B)$$

$$P(¬A) = 1 - P(A)$$

$$P(A ∧ B) = P(A|B) P(B)$$ (the product rule)

We can restate the product rule as: for A and B to be true, we need B to be true, and we also need A to be true, given B.

Bayes' Rule

I have known about Bayes' Rule since college. This is an easy way to remember it (and derive it).

From the product rule

$$P(A ∧ B) = P(A|B) P(B)$$ $$P(A ∧ B) = P(B|A) P(A)$$

Bayes' Rule

$$P(A|B) P(B) = P(B|A) P(A)$$

solve for $P(B|A)$ $$P(B|A) = \frac{P(A|B) P(B)}{P(A)}$$

For what is Bayes' Rule good? It provides a basis for diagnosing causes given the evidence, or effects we observe. We see a problem, like a fever or smoke coming out of our car's hood. We can treat the symptom, but it is usually better to treat the cause. That means we need to find the cause. Using this framework, Bayes' Rule becomes:

$$P(cause|symptom) = \frac{P(symptom|cause) P(cause)}{P(symptom)}$$

If we know $P(symptom|cause)$, and $P(cause)$, and $P(symptom)$, we can calculate the odds of the particular diagnosis.

Application of Bayes' Rule

Bayes was both a minister and a mathematician, as was his friend Richard Price. After Bayes' death, Price discovered the formula among Bayes' papers and used it as the basis of an argument for miracles (and the existence of God), trying to refute David Hume.

Today Bayes' rule is pervasive in statistics, artificial intelligence, and machine learning. Not so much in religion.

We can apply Bayes' rule to the current coronavirus epidemic, COVID-19.

According to the Centers for Disease Control (CDC)

Current symptoms reported for patients with COVID-19 have included mild to severe respiratory illness with fever, cough, and difficulty breathing.

Let's assume that 1 in 20 people report these symptoms.

$$P(S) = 1/20$$

We assume that 1 person in 100,000 has the coronavirus.

$$P(CV) = 1/100000$$

The coronavirus causes these symptoms in half the afflicted patients.

$$P(S|CV) = 0.50$$

If I have the symptoms of a respiratory illness, what is the chance that I have the coronavirus?

$$P(CV|S) = \frac{P(S|CV)P(CV)}{P(S)}$$

In [45]:
P_CV_S = 0.5 * (1/100000)/(1/20)
In [46]:
P_CV_S
Out[46]:
0.0001
In [47]:
P_CV_S * 100
Out[47]:
0.01

That is, .01 %. In 10,000 patients who report respiratory symptoms, only 1 would actually have the coronavirus, based on my fabricated data.

We can refine the calculation by looking at other factors, like traveling in China in the last few weeks. There also are tests, but

A study in the journal Radiology showed five out of 167 patients tested negative for the disease despite lung scans showing they were ill. They then tested positive for the virus at a later date. (https://www.bbc.com/news/health-51491763 "BBC News")

Thus, testing negative does not guarantee that you do NOT have the disease. Testing is an ongoing issue, involve both false negatives (showing that you do not have the disease when you do), and false positives (showing that you have the disease when you do not.)

In any event, assuming that you can nail down the respective probabilities, you can use Bayes' Rule to calculate the odds. A lot of public health workers are now trying to do exactly that. (https://www.kaggle.com/sudalairajkumar/novel-corona-virus-2019-dataset)

We should also caution that some of these numbers are moving targets. $P(CV)$ might be changing daily. $P(S|CV)$ is likely a more stable statistic. $P(S)$ might change every year during flu season.

Uncertainty and Rational Decisions

I propose the following game. You roll a fair die, with 6 sides. If the number that appears is even, you get that number of dollars. If the number is odd, you pay that number of dollars. For example, you roll a 4, you get \$4. You roll a 3, you pay me \\$3.

How much would you be willing to pay to play this game?

Using economic decision theory, we can calculate the expected value of the game by summing the expected value of all possible outcomes.

  • roll 1, pay \$1, P(1) = 1/6, EV(1) = -\\$1 * P(1) = -\$1/6

  • roll 2, get \$2, P(2) = 1/6, EV(2) = \\$2 * P(2) = +\$2/6

  • roll 3, pay \$3, P(3) = 1/6, EV(3) = -\\$3 * P(3) = -\$3/6

  • roll 4, get \$4, P(4) = 1/6, EV(4) = \\$4 * P(4) = +\$4/6

  • roll 5, pay \$5, P(5) = 1/6, EV(5) = -\\$5 * P(5) = -\$5/6

  • roll 6, get \$6, P(6) = 1/6, EV(6) = \\$6 * P(6) = +\$6/6

The total expected value is -1/6 + 2/6 - 3/6 + 4/6 - 5/6 + 6/6 = \$3/6 = \\$0.50

That is, if you pay 50 cents each time, you should break even if you play enough games.

The expected value calculation is at the heart of economic decision theory. It has the advantage that if you can accurately identify all the options, and their respective probabilities and payoffs, you can arrive at the optimum decision. It has the disadvantage that you can rarely find all the options and their accurate probabilities and payoffs, so you end up making up numbers (as I did in the coronavirus example above), and you reach erroneous conclusions.

This is largely what happened during the financial crisis when you were in elementary school, and boatloads of people lost their homes and their jobs. Thanks, in part, to good old economic decision theory.

Suppose you have a loaded die, that is, one that is not fair. On this die an odd number is three times as likely as an even number. We can model such a die with a probability distribution.

PROBABILITY DISTRIBUTION

Let us begin by specifying discrete probability distributions. The class ProbDist defines a discrete probability distribution. It is discrete because we can enumerate the finite number of $\omega$ sample points. We name our random variable and then assign probabilities to the different values of the random variable. Assigning probabilities to the values works similarly to that of using a dictionary with keys being the Value and we assign to it the probability. This is possible because of the magic methods _ getitem _ and _ setitem _ which store the probabilities in the prob dict of the object.

In [5]:
psource(ProbDist)

class ProbDist:
    """A discrete probability distribution. You name the random variable
    in the constructor, then assign and query probability of values.
    >>> P = ProbDist('Flip'); P['H'], P['T'] = 0.25, 0.75; P['H']
    0.25
    >>> P = ProbDist('X', {'lo': 125, 'med': 375, 'hi': 500})
    >>> P['lo'], P['med'], P['hi']
    (0.125, 0.375, 0.5)
    """

    def __init__(self, varname='?', freqs=None):
        """If freqs is given, it is a dictionary of values - frequency pairs,
        then ProbDist is normalized."""
        self.prob = {}
        self.varname = varname
        self.values = []
        if freqs:
            for (v, p) in freqs.items():
                self[v] = p
            self.normalize()

    def __getitem__(self, val):
        """Given a value, return P(value)."""
        try:
            return self.prob[val]
        except KeyError:
            return 0

    def __setitem__(self, val, p):
        """Set P(val) = p."""
        if val not in self.values:
            self.values.append(val)
        self.prob[val] = p

    def normalize(self):
        """Make sure the probabilities of all values sum to 1.
        Returns the normalized distribution.
        Raises a ZeroDivisionError if the sum of the values is 0."""
        total = sum(self.prob.values())
        if not isclose(total, 1.0):
            for val in self.prob:
                self.prob[val] /= total
        return self

    def show_approx(self, numfmt='{:.3g}'):
        """Show the probabilities rounded and sorted by key, for the
        sake of portable doctests."""
        return ', '.join([('{}: ' + numfmt).format(v, p)
                          for (v, p) in sorted(self.prob.items())])

    def __repr__(self):
        return "P({})".format(self.varname)

Now we create our loaded die.

In [48]:
P = ProbDist('loadeddie', {1: 3, 2: 1, 3: 3, 4: 1, 5:3, 6:1})
In [49]:
P[1]
Out[49]:
0.25

Note that P[1] is not 3, but .25, which is the normalized value for this outcome. The normalize() method gets the total of individual probability values, and then assigns each value its normalized value by dividing it by the total.

In this case, the result is that each odd number is 3 times as likely as an even number. The total of all the probabilities still sums to 1.

In [50]:
P[2]
Out[50]:
0.08333333333333333
In [51]:
P[3]
Out[51]:
0.25
In [52]:
P.show_approx()
Out[52]:
'1: 0.25, 2: 0.0833, 3: 0.25, 4: 0.0833, 5: 0.25, 6: 0.0833'

In the case of P, the constructor normalized the probabilities. We can do it after the fact. Here we create an unfair coin.

In [53]:
C = ProbDist('trickcoin')

We can use the overloaded _ setitem _ and _ getitem _ methods.

In [20]:
C['heads'] = 10
C['tails'] = 30
In [21]:
(C['heads'],C['tails'])
Out[21]:
(10, 30)
In [22]:
C.normalize()
Out[22]:
P(trickcoin)
In [23]:
(C['heads'],C['tails'])
Out[23]:
(0.25, 0.75)

Below we define the weather probability distribution from the textbook. Note that the input values are already normalized, that is, they sum to 1.

In [37]:
W = ProbDist('weather',{'sunny': .6, 'rain':.1, 'cloudy':.29, 'snow':.01})
In [42]:
W.show_approx()
Out[42]:
'cloudy: 0.29, rain: 0.1, snow: 0.01, sunny: 0.6'

Joint Probability Distribution

The helper function event_values returns a tuple of the values of variables in event. An event is specified by a dict where the keys are the names of variables and the corresponding values are the value of the variable. Variables are specified with a list. The ordering of the returned tuple is same as those of the variables.

Alternatively if the event is specified by a list or tuple of equal length of the variables. Then the events tuple is returned as it is.

In [24]:
event = {'A': 10, 'B': 9, 'C': 8}
variables = ['C', 'A']
event_values(event, variables)
Out[24]:
(8, 10)

A probability model is completely determined by the joint distribution for all of the random variables. (Section 13.3) The probability module implements these as the class JointProbDist which inherits from the ProbDist class. This class specifies a discrete probability distributed over a set of variables.

In [25]:
psource(JointProbDist)

class JointProbDist(ProbDist):
    """A discrete probability distribute over a set of variables.
    >>> P = JointProbDist(['X', 'Y']); P[1, 1] = 0.25
    >>> P[1, 1]
    0.25
    >>> P[dict(X=0, Y=1)] = 0.5
    >>> P[dict(X=0, Y=1)]
    0.5"""

    def __init__(self, variables):
        self.prob = {}
        self.variables = variables
        self.vals = defaultdict(list)

    def __getitem__(self, values):
        """Given a tuple or dict of values, return P(values)."""
        values = event_values(values, self.variables)
        return ProbDist.__getitem__(self, values)

    def __setitem__(self, values, p):
        """Set P(values) = p.  Values can be a tuple or a dict; it must
        have a value for each of the variables in the joint. Also keep track
        of the values we have seen so far for each variable."""
        values = event_values(values, self.variables)
        self.prob[values] = p
        for var, val in zip(self.variables, values):
            if val not in self.vals[var]:
                self.vals[var].append(val)

    def values(self, var):
        """Return the set of possible values for a variable."""
        return self.vals[var]

    def __repr__(self):
        return "P({})".format(self.variables)

Values for a Joint Distribution is a an ordered tuple in which each item corresponds to the value associated with a particular variable. For Joint Distribution of X, Y where X, Y take integer values this can be something like (18, 19).

To specify a Joint distribution we first need an ordered list of variables.

In [71]:
variables = ['X', 'Y']
j = JointProbDist(variables)
j
Out[71]:
P(['X', 'Y'])

Like the ProbDist class JointProbDist also employes magic methods to assign probability to different values. The probability can be assigned in either of the two formats for all possible values of the distribution. The event_values call inside _ getitem _ and _ setitem _ does the required processing to make this work.

In [77]:
j[1,1] = 0.2
j[dict(X=0, Y=1)] = 0.5

(j[1,1], j[0,1])
Out[77]:
(0.2, 0.5)

It is also possible to list all the values for a particular variable using the values method.

In [80]:
j.values('X')
Out[80]:
[1, 0]
In [82]:
j.show_approx()
Out[82]:
'(0, 1): 0.5, (1, 1): 0.2'

Inference Using Full Joint Distributions

In this section we use Full Joint Distributions to calculate the posterior distribution given some evidence. We represent evidence by using a python dictionary with variables as dict keys and dict values representing the values.

This is illustrated in Section 13.3 of the book. The functions enumerate_joint and enumerate_joint_ask implement this functionality. Under the hood they implement Equation 13.9 from the book.

$$\textbf{P}(X | \textbf{e}) = \alpha \textbf{P}(X, \textbf{e}) = \alpha \sum_{y} \textbf{P}(X, \textbf{e}, \textbf{y})$$

Here α is the normalizing factor. X is our query variable and e is the evidence. According to the equation we enumerate on the remaining variables y (not in evidence or query variable) i.e. all possible combinations of y

We will be using the same example as the book. Let us create the full joint distribution from Figure 13.3.

In [29]:
full_joint = JointProbDist(['Cavity', 'Toothache', 'Catch'])
full_joint[dict(Cavity=True, Toothache=True, Catch=True)] = 0.108
full_joint[dict(Cavity=True, Toothache=True, Catch=False)] = 0.012
full_joint[dict(Cavity=True, Toothache=False, Catch=True)] = 0.016
full_joint[dict(Cavity=True, Toothache=False, Catch=False)] = 0.064
full_joint[dict(Cavity=False, Toothache=True, Catch=True)] = 0.072
full_joint[dict(Cavity=False, Toothache=False, Catch=True)] = 0.144
full_joint[dict(Cavity=False, Toothache=True, Catch=False)] = 0.008
full_joint[dict(Cavity=False, Toothache=False, Catch=False)] = 0.576

The above data corresponds to the following table:

 toothache ¬toothache
 catch¬catch catch¬catch
cavity .108.012.072.008
¬cavity .016.064.144.576
Joint Distribution for Toothaches

Let us now look at the enumerate_joint function returns the sum of those entries in P consistent with e,provided variables is P's remaining variables (the ones not in e). Here, P refers to the full joint distribution. The function uses a recursive call in its implementation. The first parameter variables refers to remaining variables. The function in each recursive call keeps on variable constant while varying others.

In [30]:
psource(enumerate_joint)

def enumerate_joint(variables, e, P):
    """Return the sum of those entries in P consistent with e,
    provided variables is P's remaining variables (the ones not in e)."""
    if not variables:
        return P[e]
    Y, rest = variables[0], variables[1:]
    return sum([enumerate_joint(rest, extend(e, Y, y), P)
                for y in P.values(Y)])

Let us assume we want to find P(Toothache=True). This can be obtained by marginalization (Equation 13.6). We can use enumerate_joint to solve for this by taking Toothache=True as our evidence. enumerate_joint will return the sum of probabilities consistent with evidence i.e. Marginal Probability.

In [32]:
evidence = dict(Toothache=True)
variables = ['Cavity', 'Catch'] # variables not part of evidence
ans1 = enumerate_joint(variables, evidence, full_joint)
ans1
Out[32]:
0.19999999999999998

This total is $.108 + .012 + .016 + .064$

This is the probability of having a toothache. It corresponds to sum of the values in the left half of the above table.

You can verify the result from our definition of the full joint distribution. We can use the same function to find more complex probabilities like P(Cavity=True and Toothache=True)

In [33]:
evidence = dict(Cavity=True, Toothache=True)
variables = ['Catch'] # variables not part of evidence
ans2 = enumerate_joint(variables, evidence, full_joint)
ans2
Out[33]:
0.12

Being able to find sum of probabilities satisfying given evidence allows us to compute conditional probabilities like P(Cavity=True | Toothache=True) as we can rewrite this as $$P(Cavity=True | Toothache = True) = \frac{P(Cavity=True \ and \ Toothache=True)}{P(Toothache=True)}$$

We have already calculated both the numerator and denominator.

In [34]:
ans2/ans1
Out[34]:
0.6

We might be interested in the probability distribution of a particular variable conditioned on some evidence. This can involve doing calculations like above for each possible value of the variable. This has been implemented slightly differently using normalization in the function enumerate_joint_ask which returns a probability distribution over the values of the variable X, given the {var:val} observations e, in the JointProbDist P. The implementation of this function calls enumerate_joint for each value of the query variable and passes extended evidence with the new evidence having X = xi. This is followed by normalization of the obtained distribution.

In [35]:
psource(enumerate_joint_ask)

def enumerate_joint_ask(X, e, P):
    """Return a probability distribution over the values of the variable X,
    given the {var:val} observations e, in the JointProbDist P. [Section 13.3]
    >>> P = JointProbDist(['X', 'Y'])
    >>> P[0,0] = 0.25; P[0,1] = 0.5; P[1,1] = P[2,1] = 0.125
    >>> enumerate_joint_ask('X', dict(Y=1), P).show_approx()
    '0: 0.667, 1: 0.167, 2: 0.167'
    """
    assert X not in e, "Query variable must be distinct from evidence"
    Q = ProbDist(X)  # probability distribution for X, initially empty
    Y = [v for v in P.variables if v != X and v not in e]  # hidden variables.
    for xi in P.values(X):
        Q[xi] = enumerate_joint(Y, extend(e, X, xi), P)
    return Q.normalize()

Let us find P(Cavity | Toothache=True) using enumerate_joint_ask.

In [36]:
query_variable = 'Cavity'
evidence = dict(Toothache=True)
ans = enumerate_joint_ask(query_variable, evidence, full_joint)
(ans[True], ans[False])
Out[36]:
(0.6, 0.39999999999999997)

You can verify that the first value is the same as we obtained earlier by manual calculation.

In [64]:
full_joint.variables
Out[64]:
['Cavity', 'Toothache', 'Catch']
In [ ]: