Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,21 @@ is `0.x` the public API may change with a minor bump, always with an entry here.
- Frozen-behaviour fixtures under `tests/golden/`, generated from the
pre-extraction implementation, with the tooling that built them in `tools/`.
- Initial architecture decision records under `docs/decisions/`.
- The GARG-AML scoring core, moved across from the research repository with its
function bodies unchanged: `_blocks` (the 3 undirected and 9 directed block
densities), `_ordering` (the level assignment), `measures` (per-node block
measures), `scores` (aggregation into the score), `preprocess` (Louvain
reduction, hub removal) and `features` (neighbourhood summary statistics).
- `tests/test_golden.py`, comparing every stage against the frozen fixtures, and
`tests/test_equivalence.py`, comparing the moved code against the
implementation it came from over 22 graph shapes in both directions. The
latter needs the research repository present and is removed once the
extraction is complete.

### Notes

- Public names are still the research repository's (`GARG_AML_node_*_measures`,
`define_gargaml_scores`, ...). Renaming to PEP 8, type hints and NumPy-style
docstrings follow in the next release step, with the fixtures green throughout.
- The original's bare `except:` around the neighbour statistics is written as the
explicit empty check it always was. Same result, verified by both test layers.
22 changes: 21 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ dependencies = ["numpy", "pandas", "networkx>=3.0", "scipy"]
progress = ["tqdm"]
parallel = ["joblib"]
sklearn = ["scikit-learn"]
dev = ["ruff", "mypy", "pytest", "pytest-cov", "pre-commit"]
dev = ["ruff", "mypy", "pytest", "pytest-cov", "pre-commit", "pandas-stubs"]
docs = ["mkdocs-material", "mkdocstrings[python]"]

[project.urls]
Expand Down Expand Up @@ -70,6 +70,19 @@ max-complexity = 10
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["D"] # tests document themselves by name
"tools/*" = ["D"]
# The else-branches in _blocks.py encode the frozen degenerate-case constants
# (docs/decisions/0003): an empty off-diagonal block has density 1, everything
# else falls back to 0. An explicit if/else keeps each fallback visible beside
# the condition that triggers it; SIM108 would bury them at the end of a
# ternary, in the one file where those values most need to be obvious.
"src/garg_aml/_blocks.py" = ["SIM108"]
# TEMPORARY, remove in Phase 3. Phase 2 moves the implementation across with
# its function bodies unchanged, so that a golden-fixture mismatch can only
# mean the move was wrong. These three rules all ask for a rewrite of a body
# (ternaries, unpacking instead of list concatenation, no inplace=True) --
# correct requests, but ones that belong to the rename-and-modernise pass,
# where the fixtures are already green and can vouch for each change.
"src/garg_aml/*.py" = ["SIM108", "RUF005", "PD002"]

# --- Types ------------------------------------------------------------------
[tool.mypy]
Expand All @@ -83,6 +96,13 @@ warn_unused_ignores = true
module = ["networkx.*", "scipy.*"]
ignore_missing_imports = true

# Phase 2 moves the implementation across verbatim so that any fixture mismatch
# points at the move rather than at a rewrite; annotations are Phase 3. Delete
# this override then, and the global disallow_untyped_defs takes effect.
[[tool.mypy.overrides]]
module = ["garg_aml.*"]
disallow_untyped_defs = false

# --- Tests ------------------------------------------------------------------
[tool.pytest.ini_options]
testpaths = ["tests"]
Expand Down
194 changes: 194 additions & 0 deletions src/garg_aml/_blocks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
"""
Block densities of a second-order ego graph's adjacency matrix.

Each function takes the ordered adjacency matrix and the block sizes, and
returns the block's density over its *free* entries together with the count of
those entries. Entries that are structurally fixed -- the diagonal, the ego
node's own row and column -- are excluded from both, which is why the returned
size is often smaller than the block.

Three functions for the undirected analysis (paper section 3.2) and nine for the
directed one (section 3.3). Copied unchanged from the research repository; the
degenerate-case constants are deliberate, see docs/decisions/0003.
"""


def measure_1_function(piece_1_dim, adj_full):
"""Density among the ego node and its second-order neighbours."""
piece_1 = adj_full[: piece_1_dim[0], : piece_1_dim[1]]
total_sum_1 = piece_1.sum()
total_size_1 = piece_1.size
reduced_size_1 = total_size_1 - (3 * piece_1_dim[0]) + 2

if reduced_size_1 > 0:
rel_1 = total_sum_1 / reduced_size_1
else:
rel_1 = 0

return rel_1, reduced_size_1


def measure_2_function(piece_1_dim, piece_2_dim, adj_full):
"""Density between first-order and second-order neighbours."""
piece_2 = adj_full[
piece_1_dim[0] : piece_1_dim[0] + piece_2_dim[0], : piece_2_dim[1]
]
total_sum_2 = piece_2.sum()
reduced_sum_2 = total_sum_2 - piece_2_dim[0]
total_size_2 = piece_2.size
reduced_size_2 = total_size_2 - piece_2_dim[0]

if reduced_size_2 > 0:
rel_2 = reduced_sum_2 / reduced_size_2
else:
rel_2 = 1

return rel_2, reduced_size_2


def measure_3_function(piece_1_dim, piece_2_dim, piece_3_dim, adj_full):
"""Density among the first-order neighbours."""
piece_3 = adj_full[piece_1_dim[0] :, piece_2_dim[1] :]
total_sum_3 = piece_3.sum()
total_size_3 = piece_3.size
reduced_size_3 = total_size_3 - piece_3_dim[0]

if reduced_size_3 > 0:
rel_3 = total_sum_3 / reduced_size_3
else:
rel_3 = 0

return rel_3, reduced_size_3


def measure_00_function(adj_full, size_0):
"""Density among level-0 nodes (senders)."""
piece_00 = adj_full[:size_0, :size_0]
total_sum_00 = piece_00.sum()
total_size_00 = piece_00.size
reduced_size_00 = total_size_00 - (3 * size_0) + 2

if reduced_size_00 > 0:
rel_00 = total_sum_00 / reduced_size_00
else:
rel_00 = 0

return rel_00, reduced_size_00


def measure_01_function(adj_full, size_0, size_1):
"""Density from level 0 (senders) to level 1 (mules)."""
piece_01 = adj_full[:size_0, size_0 : size_0 + size_1]
total_sum_01 = piece_01.sum()
total_size_01 = piece_01.size

if total_size_01 > 0:
rel_01 = total_sum_01 / total_size_01
else:
rel_01 = 1 # Since block only contains sure connections => full sum

return rel_01, total_size_01


def measure_02_function(adj_full, size_0, size_1, size_2):
"""Density from level 0 (senders) to level 2 (receivers)."""
piece_02 = adj_full[:size_0, size_0 + size_1 :]
total_sum_02 = piece_02.sum()
total_size_02 = piece_02.size
reduced_size_02 = total_size_02 - size_2

if reduced_size_02 > 0:
rel_02 = total_sum_02 / reduced_size_02
else:
rel_02 = 0

return rel_02, reduced_size_02


def measure_10_function(adj_full, size_0, size_1):
"""Density from level 1 (mules) back to level 0 (senders)."""
piece_10 = adj_full[size_0 : size_0 + size_1, :size_0]
total_sum_10 = piece_10.sum()
total_size_10 = piece_10.size

if total_size_10 > 0:
rel_10 = total_sum_10 / total_size_10
else:
rel_10 = 0

return rel_10, total_size_10


def measure_11_function(adj_full, size_0, size_1):
"""Density among level-1 nodes (mules)."""
piece_11 = adj_full[size_0 : size_0 + size_1, size_0 : size_0 + size_1]
total_sum_11 = piece_11.sum()
total_size_11 = piece_11.size
reduced_size_11 = total_size_11 - size_1

if reduced_size_11 > 0:
rel_11 = total_sum_11 / reduced_size_11
else:
rel_11 = 0

return rel_11, reduced_size_11


def measure_12_function(adj_full, size_0, size_1, size_2):
"""Density from level 1 (mules) to level 2 (receivers)."""
piece_12 = adj_full[size_0 : size_0 + size_1, size_0 + size_1 :]
total_sum_12 = piece_12.sum()
total_size_12 = piece_12.size

if total_size_12 > 0:
rel_12 = total_sum_12 / total_size_12
elif size_2 > 0:
rel_12 = 1 # Since block only contains sure connections => full sum
else:
rel_12 = 0 # No connections at all

return rel_12, total_size_12


def measure_20_function(adj_full, size_0, size_1, size_2):
"""Density from level 2 (receivers) back to level 0 (senders)."""
piece_20 = adj_full[size_0 + size_1 :, :size_0]
total_sum_20 = piece_20.sum()
total_size_20 = piece_20.size
reduced_size_20 = total_size_20 - size_2

if reduced_size_20 > 0:
rel_20 = total_sum_20 / reduced_size_20
else:
rel_20 = 0

return rel_20, reduced_size_20


def measure_21_function(adj_full, size_0, size_1):
"""Density from level 2 (receivers) back to level 1 (mules)."""
piece_21 = adj_full[size_0 + size_1 :, size_0 : size_0 + size_1]
total_sum_21 = piece_21.sum()
total_size_21 = piece_21.size

if total_size_21 > 0:
rel_21 = total_sum_21 / total_size_21
else:
rel_21 = 0

return rel_21, total_size_21


def measure_22_function(adj_full, size_0, size_1, size_2):
"""Density among level-2 nodes (receivers)."""
piece_22 = adj_full[size_0 + size_1 :, size_0 + size_1 :]
total_sum_22 = piece_22.sum()
total_size_22 = piece_22.size
reduced_size_22 = total_size_22 - size_2

if reduced_size_22 > 0:
rel_22 = total_sum_22 / reduced_size_22
else:
rel_22 = 0

return rel_22, reduced_size_22
73 changes: 73 additions & 0 deletions src/garg_aml/_ordering.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""
Ordering of a second-order ego graph's nodes into its analysis blocks.

The block structure GARG-AML measures only appears under a specific node order.
Undirected (section 3.2): ``[ego, second-order neighbours, first-order
neighbours]``. Directed (section 3.3): ``[level 0, level 1, level 2]``, where a
node at distance two sits at level 0 when no directed path of length two reaches
it in either direction -- Eq. (11) applied to the graph and to its reverse.

Copied unchanged from the research repository.
"""

import networkx as nx


def GARG_AML_nodeselection_undirected(G_ego_second, node):
"""Order as ego, second-order neighbours, first-order neighbours."""
nodes_1 = list(nx.ego_graph(G_ego_second, node).nodes)
nodes_1.remove(node)
nodes_2 = list(G_ego_second.nodes)
nodes_2.remove(node)
for n in nodes_1:
nodes_2.remove(n)

# For undirected networks, specific order to obtain scores
# (group node with second order neighbours)
nodes_ordered = [node] + nodes_2 + nodes_1

return nodes_1, nodes_2, nodes_ordered


def GARG_AML_nodeselection_directed(
G_ego_second, G_ego_second_und, G_ego_second_rev, node
):
"""Order by level: senders, mules, receivers."""
nodes_1 = list(nx.ego_graph(G_ego_second_und, node).nodes)
nodes_1.remove(node)
nodes_2 = list(G_ego_second.nodes)

nodes_2_s = list(nx.ego_graph(G_ego_second, node, radius=2).nodes)

nodes_2_rs = list(nx.ego_graph(G_ego_second_rev, node, radius=2).nodes)

nodes_0 = list(
set(nodes_2)
.difference(set(nodes_2_s))
.difference(set(nodes_2_rs))
.difference(set(nodes_1))
)

nodes_0 = [node] + nodes_0

for n in nodes_0:
nodes_2.remove(n)
for n in nodes_1:
nodes_2.remove(n)

# For directed network, specific order to obtain scores (in order of "group")
nodes_ordered = nodes_0 + nodes_1 + nodes_2

return nodes_0, nodes_1, nodes_2, nodes_ordered


def GARG_AML_nodeselection(
G_ego_second, node, directed, G_ego_second_und=None, G_ego_second_rev=None
):
"""Dispatch to the directed or undirected ordering."""
if directed:
return GARG_AML_nodeselection_directed(
G_ego_second, G_ego_second_und, G_ego_second_rev, node
)
else:
return GARG_AML_nodeselection_undirected(G_ego_second, node)
Loading
Loading