Recipe: compile-once evaluate-many

In this chapter we show how to evaluate the same compiled program multiple times using updated weights and/or evidence.

0. The model

In this chapter we will use the model of the smokers example:

model = """
0.3::stress(X) :- person(X).
0.2::influences(X,Y) :- person(X), person(Y).

smokes(X) :- stress(X).
smokes(X) :- friend(X,Y), influences(Y,X), smokes(Y).

0.4::asthma(X) :- smokes(X).

person(1).
person(2).
person(3).
person(4).

friend(1,2).
friend(2,1).
friend(2,4).
friend(3,2).
friend(4,2).

evidence(smokes(2),true).
evidence(influences(4,2),false).

query(smokes(1)).
query(smokes(2)).
query(smokes(3)).
query(smokes(4)).
query(asthma(1)).
query(asthma(2)).
query(asthma(3)).
query(asthma(4)).
"""

1. Compiling the model

In ProbLog “compiling the model” refers to grounding the model, breaking cycles and applying knowledge compilation to transform it into a logical form on which we can efficiently apply weighted model counting.

In ProbLog these steps are done transparently using the method create_from on an Evaluatable object.

from problog.program import PrologString
from problog import get_evaluatable

# Parse the Prolog string
pl_model = PrologString(model)

# Compile the Prolog model into
knowledge = get_evaluatable().create_from(pl_model)

Some important information to keep in mind:

  • The grounding process only takes into information relevant to queries and evidence specified in the model. This means your model should include evidence facts for all goals that you want to condition on.
  • ProbLog offers different evaluatables (e.g. d-DNNF, SDD, BDD). The one used depends on your system. If you want to force a particular choice you can pass a string to the get_evaluatable function specifying which one to use (e.g. ddnnf, sdd and bdd).

We can now evaluate the model using the parameters and evidence specified in the model.

knowledge.evaluate()
{smokes(1): 0.5087719298245615,
 smokes(2): 1.0,
 smokes(3): 0.44000000000000006,
 smokes(4): 0.44000000000000006,
 asthma(1): 0.2035087719298245,
 asthma(2): 0.4000000000000001,
 asthma(3): 0.17600000000000002,
 asthma(4): 0.17600000000000002}

2. Evaluating with custom evidence

We will now evaluate the model with custom evidence. This mechanism is also used in the modes LFI and DT to avoid compiling the same theory multiple times.

Specifying custom evidence is only allowed on terms that were already defined as evidence or query in the original model. The evidence can be set by passing the argument evidence to the evaluate function. This dictionary should contain a Term-to-value mapping.

We first define the relevant terms in Python. Note that we have to wrap numbers in a Constant term.

from problog.logic import Term, Constant

# Construct the term 'smokes(2)'
smokes_2 = Term('smokes', Constant(2))

# Construct the term 'influences(4, 2)'
influences_4_2 = Term('influences', Constant(4), Constant(2))

We can now overwrite the evidence from the model, for example, by stating that person 4 does influence person 2.

knowledge.evaluate(evidence={influences_4_2: True})
{smokes(1): 0.37139999999999995,
 smokes(2): 0.5393999999999999,
 smokes(3): 0.375516,
 smokes(4): 0.3478800000000001,
 asthma(1): 0.14856,
 asthma(2): 0.21575999999999992,
 asthma(3): 0.1502064,
 asthma(4): 0.13915200000000003}

Note that all other evidence has been discarded, that is, the observation that person 2 smokes has been discarded. In order to keep other evidence from the model, we can specify the option keep_evidence=True.

knowledge.evaluate(evidence={influences_4_2: True}, keep_evidence=True)
{smokes(1): 0.47052280311457173,
 smokes(2): 1.0,
 smokes(3): 0.4399999999999999,
 smokes(4): 0.6449388209121248,
 asthma(1): 0.18820912124582867,
 asthma(2): 0.3999999999999999,
 asthma(3): 0.17599999999999996,
 asthma(4): 0.2579755283648499}

This is equivalent to setting smokes(2) to True explicitly.

We can also discard a specific piece of evidence by giving it the value None. Note that you can also use the constant none in the original model for evidence that you want to use later.

knowledge.evaluate(evidence={smokes_2: None}, keep_evidence=True)
{smokes(1): 0.34199999999999997,
 smokes(2): 0.34199999999999997,
 smokes(3): 0.34788000000000013,
 smokes(4): 0.34787999999999997,
 asthma(1): 0.13679999999999998,
 asthma(2): 0.13679999999999998,
 asthma(3): 0.139152,
 asthma(4): 0.139152}

This keeps influences(4,2) as false (because we specified keep_evidence=True), but it removes the observation for smokes(2).

Conclusion

You can re-evaluate a precompiled circuit with different observations. You have to keep in mind that:

  • all evidence should be pre-declared in the model (which has a computational cost)
  • if you want to modify a small number of values, you can use keep_evidence=True
  • if you want to set an observation you can use True or False, to unset it use None
  • you should not not turn on evidence propagation

3. Evaluating with custom parameters

ProbLog only uses the probabilities of the model during the evaluation phase. It is thus possible to overwrite them for that phase. There are two mechanisms that can be used: by passing a weights dictionary and using a custom semiring.

3.1 Using a weights dictionary

You can pass custom weights to the evaluate function by passing a dictionary mapping Terms on probabilities. The disadvantage of this approach is that it requires you to know the internal names used by ProbLog.

You can find out these names by using the get_names() function.

knowledge.get_names()
OrderedSet([(smokes(1), 11), (smokes(2), 13), (smokes(3), 17), (smokes(4), 21), (asthma(1), 23), (asthma(2), 26), (asthma(3), 30), (asthma(4), 32), (choice(0,0,stress(1),1), 1), (choice(10,0,influences(2,1),2,1), 2), (choice(0,0,stress(2),2), 3), (choice(10,0,influences(1,2),1,2), 4), (choice(0,0,stress(4),4), 5), (choice(10,0,influences(2,4),2,4), 6), (influences(4,2), 7), (choice(10,0,influences(2,3),2,3), 14), (choice(0,0,stress(3),3), 16), (choice(31,0,asthma(1),1), 22), (choice(31,0,asthma(2),2), 25), (choice(31,0,asthma(3),3), 29), (choice(31,0,asthma(4),4), 31), (smokes(2), 24)])

You can set the weights by passing a dictionary.

choice = Term('choice')
stress = Term('stress')
c0, c1 = Constant(0), Constant(1)
term = choice(c0, c0, stress(c1), c1)

knowledge.evaluate(weights={term: 0.8})
{smokes(1): 0.883495145631068,
 smokes(2): 1.0,
 smokes(3): 0.43999999999999995,
 smokes(4): 0.43999999999999995,
 asthma(1): 0.35339805825242726,
 asthma(2): 0.4000000000000001,
 asthma(3): 0.17600000000000007,
 asthma(4): 0.17599999999999993}

The fact that you need to know the internal names is obviously a major drawback for this approach. In practice it is therefore more useful to use the semiring-based approach.

3.2 Using a custom semiring

First, we modify our model to introduce parameterized weights. In this way, we can uniquely identify each weight.

By adding arguments (e.g. p_stress(X)) we can set the probability of stress for each person individually. You can also opt to omit the argument X if you don’t need to set this parameter for each person individually (as illustrated for p_influences).

Note that we keep the fixed probability for asthma.

model_sr = """
p_stress(X)::stress(X) :- person(X).
p_influences::influences(X,Y) :- person(X), person(Y).

smokes(X) :- stress(X).
smokes(X) :- friend(X,Y), influences(Y,X), smokes(Y).

0.4::asthma(X) :- smokes(X).

person(1).
person(2).
person(3).
person(4).

friend(1,2).
friend(2,1).
friend(2,4).
friend(3,2).
friend(4,2).

evidence(smokes(2),true).
evidence(influences(4,2),false).

query(smokes(1)).
query(smokes(2)).
query(smokes(3)).
query(smokes(4)).
query(asthma(1)).
query(asthma(2)).
query(asthma(3)).
query(asthma(4)).
"""

from problog.program import PrologString
from problog import get_evaluatable

# Parse the Prolog string
pl_model_sr = PrologString(model_sr)

# Compile the Prolog model into
knowledge_sr = get_evaluatable().create_from(pl_model_sr)
Next, we define a custom semiring.
The role of the semiring is to define the operations that can be applied on the weights (such as sum, product, …).

We can reuse most of the operations defined by the default semiring (SemiringLogProbability). We only need to modify the value operation that translates a given weight to the internal value.

This function receives the ground term representing the weight (e.g. p_stress(1)). We override this function such that it looks up the corresponding value in a given dictionary.

from problog.evaluator import SemiringLogProbability

class CustomSemiring(SemiringLogProbability):

    def __init__(self, weights):
        SemiringLogProbability.__init__(self)
        self.weights = weights

    def value(self, a):
        # Argument 'a' contains ground term.  Look up its probability in the weights dictionary.
        return SemiringLogProbability.value(self, self.weights.get(a, a))

We now need to initialize all the weights in the model. To determine which weights exist in the circuit, you can use the get_weights function.

knowledge_sr.get_weights()
{1: p_stress(1),
 2: p_influences,
 3: p_stress(2),
 4: p_influences,
 5: p_stress(4),
 6: p_influences,
 7: p_influences,
 14: p_influences,
 16: p_stress(3),
 22: 0.4,
 25: 0.4,
 29: 0.4,
 31: 0.4}

We now initialize these weights based on the original model.

custom_weights = {}
for x in knowledge_sr.get_weights().values():
    if x.functor == 'p_stress':
        custom_weights[x] = 0.3
    elif x.functor == 'p_influences':
        custom_weights[x] = 0.2

custom_weights
{p_influences: 0.2,
 p_stress(1): 0.3,
 p_stress(2): 0.3,
 p_stress(3): 0.3,
 p_stress(4): 0.3}

When we evaluate this model using the custom semiring, we get the same result as the original model.

knowledge_sr.evaluate(semiring=CustomSemiring(custom_weights))
{asthma(1): 0.2035087719298245,
 asthma(2): 0.4000000000000001,
 asthma(3): 0.17600000000000002,
 asthma(4): 0.17600000000000002,
 smokes(4): 0.44000000000000006,
 smokes(1): 0.5087719298245615,
 smokes(2): 1.0,
 smokes(3): 0.44000000000000006}

We can now override the parameters by changing them in the dictionary.

For example, we can set the stress probability of person 1 to 0.8.

custom_weights[Term('p_stress', Constant(1))] = 0.8

knowledge_sr.evaluate(semiring=CustomSemiring(custom_weights))
{asthma(1): 0.35339805825242726,
 asthma(2): 0.4000000000000001,
 asthma(3): 0.17600000000000007,
 asthma(4): 0.17599999999999993,
 smokes(4): 0.43999999999999995,
 smokes(1): 0.883495145631068,
 smokes(2): 1.0,
 smokes(3): 0.43999999999999995}

Conclusion

In this chapter we demonstrated the “compile-once evaluate-many” strategy for two cases:

  • to evaluate the same structure using different observations
  • to evaluate the same structure using different parameters

Both these cases can also be combined.