Expert System Lab Walkthrough

Unit 6: Knowledge-Based Agents and Inference — Lab Walkthrough

Try the lab yourself first! This page contains complete solutions. You will learn far more by struggling with the implementation yourself than by reading the answers. If you are stuck on a specific part, read only that section. Understand the solution before looking at the next one.

Part 1: Knowledge Base Implementation

The Rule Class

A rule has two components: a list of conditions (all must be true) and a single conclusion.

class Rule:
    """
    Represents a logical rule: IF all conditions THEN conclusion.
    """
    def __init__(self, conditions, conclusion):
        # conditions: list of strings, each a fact that must be true
        # conclusion: the single fact that becomes true when all conditions met
        self.conditions = conditions   # e.g., ['fever', 'cough']
        self.conclusion = conclusion   # e.g., 'respiratory_issue'

    def can_fire(self, known_facts):
        """
        Returns True if all conditions are in known_facts.
        Uses Python's all() for concise AND-check over a list.
        """
        return all(condition in known_facts for condition in self.conditions)

    def __str__(self):
        conds = ' ∧ '.join(self.conditions)
        return f"IF {conds} THEN {self.conclusion}"

Design decisions:

  • conditions is a list because all conditions must be true (AND semantics).

  • can_fire() uses Python’s all() — returns True only if every element satisfies the condition.

  • str() provides readable output for debugging and the explanation facility.

The KnowledgeBase Class

class KnowledgeBase:
    """
    Stores facts and rules; implements the Tell-Ask interface.
    """
    def __init__(self):
        self.facts = set()       # Use set for O(1) membership testing
        self.rules = []          # Ordered list of Rule objects
        self.fired_rules = []    # Track in-order for explanation facility

    def tell(self, fact):
        """Add a fact to the knowledge base."""
        self.facts.add(fact)
        print(f"Told: {fact}")

    def add_rule(self, rule):
        """Add a Rule object to the knowledge base."""
        self.rules.append(rule)
        print(f"Added rule: {rule}")

    def ask(self, query):
        """Return True if query is a known fact."""
        return query in self.facts

Why use a set for facts? Set membership testing (x in my_set) is O(1) average case in Python. If you used a list, x in my_list would be O(n) — slow for large KBs. The fired_rules list is separate from rules so the original rule list stays intact for re-use.

Part 2: Forward Chaining Algorithm

The key structure is a while True outer loop that breaks when no new facts are produced.

def forward_chain(self):
    """
    Apply forward chaining: fire rules until no new facts can be derived.
    Returns the set of all newly inferred facts.
    """
    inferred = set()   # All facts derived during this call
    iteration = 1

    print("\n=== Forward Chaining Started ===")
    print(f"Initial facts: {self.facts}\n")

    while True:
        print(f"--- Iteration {iteration} ---")
        new_facts = set()   # Facts derived in THIS iteration only

        for rule in self.rules:
            # Can this rule fire AND is its conclusion new?
            if rule.can_fire(self.facts) and rule.conclusion not in self.facts:
                print(f"  Firing: {rule}")
                new_facts.add(rule.conclusion)
                self.fired_rules.append(rule)

        if not new_facts:
            print("  No new facts. Stopping.")
            break

        # Add new facts AFTER the inner loop (do not modify self.facts mid-scan)
        print(f"  New facts: {new_facts}")
        self.facts.update(new_facts)
        inferred.update(new_facts)
        iteration += 1
        print()

    print(f"=== Forward Chaining Complete ===")
    print(f"Inferred: {inferred}\n")
    return inferred

# Attach to class (if defined outside)
KnowledgeBase.forward_chain = forward_chain

Why use new_facts inside the loop rather than updating self.facts directly?

If you call self.facts.add(…​) inside the for rule in self.rules loop, later rules in the same iteration can see the new fact. This means a rule that should require two iterations (A → B, B → C) might fire in a single iteration, producing different behavior than the formal algorithm specifies. Using new_facts and only updating self.facts.update(new_facts) after the full scan makes each iteration’s input state well-defined and consistent.

Part 3: Domain-Specific Expert System

Here is a complete medical diagnosis KB with 18 rules covering 4 diseases.

kb = KnowledgeBase()

# --- Stage 1: Symptom grouping ---
kb.add_rule(Rule(['fever', 'cough'], 'respiratory_issue'))
kb.add_rule(Rule(['runny_nose', 'itchy_eyes'], 'allergy_symptoms'))

# --- Flu pathway ---
kb.add_rule(Rule(['respiratory_issue', 'fatigue'], 'possible_flu'))
kb.add_rule(Rule(['possible_flu', 'body_aches'], 'flu'))

# --- COVID pathway ---
kb.add_rule(Rule(['respiratory_issue', 'loss_of_taste'], 'possible_covid'))
kb.add_rule(Rule(['possible_covid', 'loss_of_smell'], 'covid'))

# --- Common cold pathway ---
kb.add_rule(Rule(['respiratory_issue', 'sore_throat'], 'possible_cold'))
kb.add_rule(Rule(['possible_cold', 'runny_nose'], 'common_cold'))

# --- Allergy pathway ---
kb.add_rule(Rule(['allergy_symptoms', 'sneezing'], 'allergies'))

# --- Flu recommendations ---
kb.add_rule(Rule(['flu'], 'recommend_rest'))
kb.add_rule(Rule(['flu'], 'recommend_fluids'))
kb.add_rule(Rule(['flu'], 'consider_antiviral'))

# --- COVID recommendations ---
kb.add_rule(Rule(['covid'], 'recommend_isolation'))
kb.add_rule(Rule(['covid'], 'recommend_covid_test'))
kb.add_rule(Rule(['covid'], 'monitor_oxygen'))

# --- Cold recommendations ---
kb.add_rule(Rule(['common_cold'], 'recommend_rest'))
kb.add_rule(Rule(['common_cold'], 'recommend_fluids'))

# --- Allergy recommendations ---
kb.add_rule(Rule(['allergies'], 'recommend_antihistamine'))
kb.add_rule(Rule(['allergies'], 'recommend_avoid_triggers'))

Test Case 1: Flu

kb.tell('fever')
kb.tell('cough')
kb.tell('fatigue')
kb.tell('body_aches')

inferred = kb.forward_chain()

print("--- Diagnosis ---")
if kb.ask('flu'):
    print("Diagnosis: Influenza")
    if kb.ask('recommend_rest'):
        print("  • Get plenty of rest")
    if kb.ask('recommend_fluids'):
        print("  • Drink lots of fluids")

Expected Forward Chaining Output

=== Forward Chaining Started ===
Initial facts: {'fever', 'cough', 'fatigue', 'body_aches'}

--- Iteration 1 ---
  Firing: IF fever ∧ cough THEN respiratory_issue
  New facts: {'respiratory_issue'}

--- Iteration 2 ---
  Firing: IF respiratory_issue ∧ fatigue THEN possible_flu
  New facts: {'possible_flu'}

--- Iteration 3 ---
  Firing: IF possible_flu ∧ body_aches THEN flu
  New facts: {'flu'}

--- Iteration 4 ---
  Firing: IF flu THEN recommend_rest
  Firing: IF flu THEN recommend_fluids
  Firing: IF flu THEN consider_antiviral
  New facts: {'recommend_rest', 'recommend_fluids', 'consider_antiviral'}

--- Iteration 5 ---
  No new facts. Stopping.

=== Forward Chaining Complete ===
Inferred: {'respiratory_issue', 'possible_flu', 'flu', 'recommend_rest',
           'recommend_fluids', 'consider_antiviral'}

Part 4: Explanation Facility

def explain(self, target_fact):
    """
    Explain how the KB derived a particular fact.
    Shows the complete rule chain from initial facts to conclusion.
    """
    print(f"\n=== Explanation for: {target_fact} ===")

    if not self.ask(target_fact):
        print(f"Cannot explain: '{target_fact}' is not in the knowledge base.")
        return

    # Build chain of relevant rules
    chain = self._build_chain(target_fact)

    if not chain:
        print(f"'{target_fact}' was given as an initial fact.")
        return

    for step, rule in enumerate(chain, 1):
        print(f"\nStep {step}:")
        print(f"  Rule applied: {rule}")
        print(f"  Conditions:  {', '.join(rule.conditions)}")
        print(f"  Inferred:    {rule.conclusion}")

    print(f"\n✓ Conclusion: {target_fact} = TRUE")

def _build_chain(self, target):
    """
    Recursively find the chain of fired rules that established target.
    Returns rules in order from earliest inference to the target.
    """
    chain = []
    for rule in self.fired_rules:
        if rule.conclusion == target:
            # Find rules that established this rule's conditions
            for cond in rule.conditions:
                chain.extend(self._build_chain(cond))
            chain.append(rule)
            break   # Only need one proof path per conclusion
    return chain

KnowledgeBase.explain = explain
KnowledgeBase._build_chain = _build_chain

Sample Explanation Output

After running forward chaining on the flu scenario:

=== Explanation for: flu ===

Step 1:
  Rule applied: IF fever ∧ cough THEN respiratory_issue
  Conditions:   fever, cough
  Inferred:     respiratory_issue

Step 2:
  Rule applied: IF respiratory_issue ∧ fatigue THEN possible_flu
  Conditions:   respiratory_issue, fatigue
  Inferred:     possible_flu

Step 3:
  Rule applied: IF possible_flu ∧ body_aches THEN flu
  Conditions:   possible_flu, body_aches
  Inferred:     flu

✓ Conclusion: flu = TRUE

Pre-Submission Checklist

Before Submitting — Run These Checks

  • Rule class has conditions list and conclusion string

  • KnowledgeBase stores facts in a set, rules in a list

  • tell() and ask() work correctly on simple examples

  • forward_chain() checks for new facts before firing

  • forward_chain() stops when no new facts are derived

  • Knowledge base has 15+ rules

  • Rules chain through at least 3 levels (symptoms → intermediate → diagnosis → recommendation)

  • Tested with 3 different scenarios

  • explain() prints a complete reasoning chain

  • All cells run without errors after RuntimeRestart and run all

Extension Challenges (Optional, No Points)

If you finish early and want to extend your system:

Optional extensions to try:

  • Confidence factors: Add a cf attribute to Rule; compute combined CFs when chaining.

  • Backward chaining: Implement a backward_chain(goal) method and compare its output to forward chaining on the same KB.

  • Interactive mode: Ask the user for symptoms one at a time via input() and build up the fact set interactively.

  • Why-not explanations: Implement explain_not(fact) that tells the user which conditions were missing to prevent a conclusion from being reached.

  • Conflict resolution: When two rules lead to contradictory conclusions, apply a priority ordering to choose between them.


Code examples adapted from aima-python, MIT License, Copyright 2016 aima-python contributors.

This work is licensed under CC BY-SA 4.0.