Exercise 3 — Walk the graph with merges

Guided practice5 min
Time
25-30 min
You need
the kit, the venv active, data/graph/ built
Deliverable
the three-line table of bearing incidents by site in Step 5, and the name of the most connected machine in Step 6

The lab kit of the course: https://github.com/hrhouma2/aiopsatlas-ml-data-diagnostics-labs-en

Goal

The reliability team asks: "Which sites have the most bearing incidents?" In the knowledge graph this is a path of three hops: category, incident, machine, site. You have no graph database. You have two CSV files and pandas. Each hop is one merge. You walk the path, answer the question, then find the most connected machine of the plant.

Setup: the commands (PowerShell, then bash)
powershell
cd aiopsatlas-ml-data-diagnostics-labs-en
.\.venv\Scripts\Activate.ps1
bash
cd aiopsatlas-ml-data-diagnostics-labs-en
source .venv/bin/activate

Create week03/walk_the_graph.py and start it with:

python
import pandas as pd

pd.set_option("display.width", 120)
The data you will touch

data/graph/nodes.csv, 207 rows: node_id, label, name.

text
node_id,label,name
M001,Machine,M001
Montreal,Site,Montreal
pump,MachineType,pump
bearing_wear,FaultCategory,bearing_wear
INC-0001,Incident,INC-0001

data/graph/edges.csv, 390 rows: source, relation, target. Read each row as a sentence.

text
source,relation,target
M001,LOCATED_AT,Toronto
M001,IS_A,pump
INC-0001,HAPPENED_ON,M001
INC-0001,HAS_CATEGORY,overheating
RelationFromToCount
LOCATED_ATMachineSite40
IS_AMachineMachineType40
HAPPENED_ONIncidentMachine155
HAS_CATEGORYIncidentFaultCategory155

Step 1 — Load and count

Load both files. Count nodes by label and edges by relation.

python
nodes = pd.read_csv("data/graph/nodes.csv")
edges = pd.read_csv("data/graph/edges.csv")
print("nodes:", len(nodes), "| edges:", len(edges))
print(nodes["label"].value_counts())
print(edges["relation"].value_counts())
text
nodes: 207 | edges: 390
label
Incident         155
Machine           40
FaultCategory      5
MachineType        4
Site               3
relation
HAPPENED_ON     155
HAS_CATEGORY    155
LOCATED_AT       40
IS_A             40

155 + 40 + 5 + 4 + 3 is 207. 155 + 155 + 40 + 40 is 390. The totals add up.

Step 2 — The neighbours of one machine

Look at M001 from both ends. Edges where it is the source, then edges where it is the target.

python
print(edges[edges["source"] == "M001"])
print(edges[edges["target"] == "M001"])
text
   source    relation    target
0    M001  LOCATED_AT  Toronto
40   M001        IS_A     pump
      source     relation target
80  INC-0001  HAPPENED_ON   M001
81  INC-0002  HAPPENED_ON   M001
82  INC-0003  HAPPENED_ON   M001
83  INC-0004  HAPPENED_ON   M001
84  INC-0005  HAPPENED_ON   M001

As a source, M001 has two edges: its site and its type. As a target, five incidents point at it. Direction matters. If you only looked at source, you would think M001 never broke.

Step 3 — One small table per relation

Split edges into four tables, one per relation. Rename source and target to say what they are. The function does it once for all four.

python
def relation(name, source_name, target_name):
    part = edges[edges["relation"] == name]
    return part.rename(columns={"source": source_name, "target": target_name})[[source_name, target_name]]

located_at = relation("LOCATED_AT", "machine", "site")
is_a = relation("IS_A", "machine", "machine_type")
happened_on = relation("HAPPENED_ON", "incident", "machine")
has_category = relation("HAS_CATEGORY", "incident", "category")
print(happened_on.head(3))
print(has_category.head(3))
text
    incident machine
80  INC-0001    M001
81  INC-0002    M001
82  INC-0003    M001
     incident      category
235  INC-0001   overheating
236  INC-0002  bearing_wear
237  INC-0003  bearing_wear

Now happened_on and has_category share a column, incident. happened_on and located_at share machine. Shared columns are where the merges will happen.

Step 4 — Two hops: machine, incident, category

Merge happened_on with has_category on incident. Each row is now a path of two edges. Filter on M001.

python
two_hop = happened_on.merge(has_category, on="incident")
print(two_hop[two_hop["machine"] == "M001"])
text
   incident machine      category
0  INC-0001    M001   overheating
1  INC-0002    M001  bearing_wear
2  INC-0003    M001  bearing_wear
3  INC-0004    M001          leak
4  INC-0005    M001    electrical

Read a row as a path: M001 had INC-0002, which has category bearing_wear. Five incidents, four different categories, two of them bearing wear.

Step 5 — Three hops: add the site

Merge two_hop with located_at on machine. Then keep bearing_wear and count by site. This is the deliverable.

python
three_hop = two_hop.merge(located_at, on="machine")
print("rows:", len(three_hop))
bearing = three_hop[three_hop["category"] == "bearing_wear"]
print(bearing["site"].value_counts())
print(pd.crosstab(three_hop["site"], three_hop["category"]))
text
rows: 155
site
Toronto        22
Montreal       18
Quebec City     8
category     bearing_wear  electrical  leak  overheating  sensor_fault
site
Montreal               18           8    14           18             8
Quebec City             8           5     6           11             3
Toronto                22          10     8           10             6

Still 155 rows: one per incident, the path did not lose or add any. Toronto has 22 bearing incidents with 13 machines. Montreal has 18 with 20 machines. Toronto's pumps are the ones to look at. The crosstab gives the whole picture in one table.

Step 6 — The most connected machine

The degree of a node is the number of edges that touch it. Count every appearance in source and target, then keep the machines.

python
degree = pd.concat([edges["source"], edges["target"]]).value_counts()
machine_degree = degree[degree.index.isin(located_at["machine"])]
print(machine_degree.head(3))
top = machine_degree.index[0]
print(f"{top}: {int(happened_on['machine'].eq(top).sum())} incidents + LOCATED_AT + IS_A = {int(machine_degree.iloc[0])} edges")
text
M017    10
M020     9
M030     9
M017: 8 incidents + LOCATED_AT + IS_A = 10 edges

M017 is a compressor in Quebec City, installed in 2014. Eight incidents in one year, plus its two fixed edges. The oldest machines break the most. You will use that in Week 4 when you build features.

Check yourself

  1. How many rows does three_hop have, and why must it be that number?
  2. Which site has the most bearing incidents, and how many?
  3. Why did edges[edges["source"] == "M001"] show no incident?
  4. What is the degree of M017, and how is it made up?
Answers
    1. Every incident has exactly one machine and one category, and every machine has exactly one site. No path is lost or doubled.
  1. Toronto, 22. Then Montreal 18 and Quebec City 8.
  2. Incidents point at the machine: INC-0001 HAPPENED_ON M001. M001 is the target of those edges, not the source.
  3. 10: eight HAPPENED_ON edges pointing at it, plus LOCATED_AT and IS_A.

Bonus (optional)

Add a fourth hop with is_a and build the crosstab of machine_type by category. Which type has the most leak incidents? Compressors should lead with 11. Then find the category that never happened on a conveyor in Montreal, if there is one.

Full solutionweek03/exercise_3_solution.py in the kit
python
"""Week 3, Exercise 3 - Graph traversal with pandas merges.

Run from the kit root, with the venv active:

    python week03/exercise_3_solution.py

Walks the NorthPeak knowledge graph (data/graph/nodes.csv, edges.csv) with
nothing but pandas merges:
  1. load nodes and edges, count them by label and by relation;
  2. the neighbours of one machine, M001;
  3. one hop per relation: four small tables;
  4. a 2-hop path machine -> incident -> category, for M001;
  5. a 3-hop path category <- incident -> machine -> site:
     which sites have the most bearing_wear incidents;
  6. the most connected node (degree).
"""

import pandas as pd

pd.set_option("display.width", 120)

# Step 1 - load
print("== Step 1: nodes and edges ==")
nodes = pd.read_csv("data/graph/nodes.csv")
edges = pd.read_csv("data/graph/edges.csv")
print("nodes:", len(nodes), "| edges:", len(edges))
print(nodes["label"].value_counts())
print(edges["relation"].value_counts())
print()

# Step 2 - neighbours of M001
print("== Step 2: neighbours of M001 ==")
print(edges[edges["source"] == "M001"])
print(edges[edges["target"] == "M001"])
print()

# Step 3 - one table per relation
print("== Step 3: one table per relation ==")


def relation(name, source_name, target_name):
    """Keep one relation and rename source/target to readable names."""
    part = edges[edges["relation"] == name]
    return part.rename(columns={"source": source_name, "target": target_name})[[source_name, target_name]]


located_at = relation("LOCATED_AT", "machine", "site")
is_a = relation("IS_A", "machine", "machine_type")
happened_on = relation("HAPPENED_ON", "incident", "machine")
has_category = relation("HAS_CATEGORY", "incident", "category")
print(happened_on.head(3))
print(has_category.head(3))
print()

# Step 4 - 2 hops: machine -> incident -> category
print("== Step 4: 2-hop path for M001 ==")
two_hop = happened_on.merge(has_category, on="incident")
print(two_hop[two_hop["machine"] == "M001"])
print()

# Step 5 - 3 hops: category <- incident -> machine -> site
print("== Step 5: bearing_wear incidents by site ==")
three_hop = two_hop.merge(located_at, on="machine")
print("rows:", len(three_hop))
bearing = three_hop[three_hop["category"] == "bearing_wear"]
print(bearing["site"].value_counts())
print()
print(pd.crosstab(three_hop["site"], three_hop["category"]))
print()

# Step 6 - degree
print("== Step 6: the most connected nodes ==")
degree = pd.concat([edges["source"], edges["target"]]).value_counts()
machine_degree = degree[degree.index.isin(located_at["machine"])]
print(machine_degree.head(3))
top = machine_degree.index[0]
print(f"{top}: {int(happened_on['machine'].eq(top).sum())} incidents + LOCATED_AT + IS_A = {int(machine_degree.iloc[0])} edges")

Run it with python week03/exercise_3_solution.py. Step 5 prints the deliverable table; Step 6 prints M017.

Stuck? Common errors

All systems — rows: 0 after a merge. The shared column has different names on the two sides, for example machine and machine_id. Print .columns of both tables and check the on= value.

All systems — rows: 6200 or more after a merge. You merged on a column that is not a key, or you merged edges with itself without filtering the relation first. Build the four small tables of Step 3 first.

All systems — KeyError: 'incident'. The rename did not apply, or you selected the columns before renaming. Follow the order of the relation function: filter, rename, select.

All systems — value_counts() shows all five categories instead of three sites. You counted category instead of site, or forgot the bearing_wear filter.

Windows only — the crosstab wraps over several lines. Add pd.set_option("display.width", 120) at the top of the script.

Linux and macOS only — python: command not found. Use python3 to create the venv. Once active, python works.