File size: 9,045 Bytes
79b0bef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4dc0836
79b0bef
 
 
 
 
 
 
 
4dc0836
79b0bef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4dc0836
79b0bef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
"""
src/nlp/cooccurrence.py
────────────────────────────────────────────────────────────────
Entity co-occurrence graph builder.

Two entities "co-occur" when they appear in the same clinical note.
Frequent co-occurrence suggests a clinical relationship β€” for
example, "hypertension" and "diabetes mellitus" appear together
often because they are common comorbidities.

This module builds a weighted undirected graph where:
  - Nodes  = unique entity texts
  - Edges  = pairs that co-occur in at least `min_count` notes
  - Weight = number of notes in which the pair co-occurs

The graph is returned as a NetworkX object so callers can
apply any NetworkX algorithm (centrality, community detection,
shortest path, etc.) without this module needing to know about
the downstream use.

The Streamlit dashboard uses pyvis to render an interactive
HTML visualisation of the graph.
────────────────────────────────────────────────────────────────
"""

from __future__ import annotations

from collections import defaultdict

from src.utils.logger import get_logger

logger = get_logger(__name__)


def build_cooccurrence_graph(
    note_entities: dict[int, list],
    entity_label:  str = "DISEASE",
    min_count:     int = 3,
    max_nodes:     int = 50,
):
    """Build a co-occurrence graph from per-note entity lists.

    Args:
        note_entities : Dict mapping note_id β†’ list of Entity objects.
                        Typically from the NER pipeline output.
        entity_label  : Only include entities with this label.
                        Defaults to ``"DISEASE"`` β€” disease-disease
                        co-occurrences are the most clinically meaningful.
        min_count     : Minimum number of notes a pair must co-occur
                        in to be included as an edge.  Lower values
                        create denser, noisier graphs.
        max_nodes     : Limit the graph to this many nodes (highest
                        degree nodes are kept) to keep the
                        visualisation readable.

    Returns:
        A :class:`networkx.Graph` with ``weight`` edge attributes
        and ``count`` node attributes (number of notes the entity
        appears in).  Returns an empty graph if networkx is not
        installed or no pairs are found.

    Example::

        graph = build_cooccurrence_graph(note_entities)
        print(graph.number_of_nodes(), graph.number_of_edges())
        # β†’ 38  91
    """
    try:
        import networkx as nx
    except ImportError:
        logger.error(
            "networkx not installed. Run: pip install networkx"
        )
        import networkx as nx  # re-raise the real error
        return nx.Graph()

    # ── Step 1: count pairwise co-occurrences ─────────────────────
    # For each note, collect the unique entity texts with the
    # target label, then count every pair.
    pair_counts:  defaultdict[tuple[str, str], int] = defaultdict(int)
    entity_counts: defaultdict[str, int]             = defaultdict(int)

    for entities in note_entities.values():
        # Deduplicate entities within this note β€” we care about
        # co-occurrence, not how many times each entity appears.
        relevant = list({
            ent.text.lower()
            for ent in entities
            if ent.label.upper() == entity_label.upper()
            and len(ent.text.strip()) >= 3
        })

        for text in relevant:
            entity_counts[text] += 1

        # Generate all unique pairs (order-independent)
        for i in range(len(relevant)):
            for j in range(i + 1, len(relevant)):
                pair = tuple(sorted([relevant[i], relevant[j]]))
                pair_counts[pair] += 1

    if not pair_counts:
        logger.warning(
            "No co-occurrence pairs found for label='%s'. "
            "Check that your NER pipeline has run and produced entities.",
            entity_label,
        )
        return nx.Graph()

    # ── Step 2: build the NetworkX graph ──────────────────────────
    graph = nx.Graph()

    # Add nodes with their document frequency as an attribute
    for entity, count in entity_counts.items():
        graph.add_node(entity, count=count)

    # Add edges that meet the minimum co-occurrence threshold
    edges_added = 0
    for (entity_a, entity_b), count in pair_counts.items():
        if count >= min_count:
            graph.add_edge(entity_a, entity_b, weight=count)
            edges_added += 1

    logger.info(
        "Co-occurrence graph: %d nodes, %d edges (min_count=%d)",
        graph.number_of_nodes(), edges_added, min_count,
    )

    # ── Step 3: prune to max_nodes if needed ──────────────────────
    # Keep the nodes with the highest degree (most connections).
    # This produces a more coherent visualisation than random pruning.
    if graph.number_of_nodes() > max_nodes:
        # Sort by degree descending and keep the top max_nodes
        top_nodes = sorted(
            graph.nodes(), key=lambda n: graph.degree(n), reverse=True
        )[:max_nodes]
        graph = graph.subgraph(top_nodes).copy()
        logger.info(
            "Graph pruned to %d nodes (max_nodes=%d)",
            graph.number_of_nodes(), max_nodes,
        )

    return graph


def graph_to_pyvis(
    graph,
    height:    str = "600px",
    bgcolor:   str = "#ffffff",
    font_color: str = "#000000",
) -> str | None:
    """Convert a NetworkX graph to an interactive pyvis HTML string.

    Node size scales with document frequency (how often the entity
    appears across the dataset).  Edge thickness scales with
    co-occurrence count.

    Args:
        graph      : NetworkX Graph from :func:`build_cooccurrence_graph`.
        height     : Height of the HTML iframe (CSS string).
        bgcolor    : Background colour hex code.
        font_color : Node label font colour.

    Returns:
        HTML string suitable for embedding in Streamlit via
        ``st.components.v1.html()``, or None if pyvis is not installed.

    Example::

        html = graph_to_pyvis(graph)
        if html:
            st.components.v1.html(html, height=620)
    """
    try:
        from pyvis.network import Network
    except ImportError:
        logger.warning(
            "pyvis not installed β€” falling back to static chart. "
            "Run: pip install pyvis"
        )
        return None

    net = Network(
        height     = height,
        bgcolor    = bgcolor,
        font_color = font_color,
        notebook   = False,
    )

    # Add nodes β€” size proportional to document frequency
    for node, attrs in graph.nodes(data=True):
        count     = attrs.get("count", 1)
        node_size = max(10, min(50, count * 2))   # clamp 10–50
        net.add_node(
            node,
            label = node,
            size  = node_size,
            title = f"{node}<br>appears in {count} notes",
        )

    # Add edges β€” width proportional to co-occurrence weight
    for source, target, attrs in graph.edges(data=True):
        weight     = attrs.get("weight", 1)
        edge_width = max(1, min(10, weight // 2))   # clamp 1–10
        net.add_edge(
            source, target,
            width = edge_width,
            title = f"co-occurs in {weight} notes",
        )

    net.set_options("""
    {
      "physics": {
        "forceAtlas2Based": {
          "gravitationalConstant": -50,
          "centralGravity": 0.01,
          "springLength": 100
        },
        "solver": "forceAtlas2Based",
        "minVelocity": 0.75
      }
    }
    """)

    return net.generate_html()


def graph_summary(graph) -> dict:
    """Return summary statistics for a co-occurrence graph.

    Useful for the dashboard metrics panel.

    Args:
        graph: NetworkX Graph.

    Returns:
        Dict with ``nodes``, ``edges``, ``density``,
        ``top_entities`` (list of (name, degree) tuples).

    Example::

        summary = graph_summary(graph)
        print(summary["top_entities"][:3])
        # β†’ [("hypertension", 28), ("diabetes", 22), ("pain", 19)]
    """
    try:
        import networkx as nx
    except ImportError:
        return {}

    if graph.number_of_nodes() == 0:
        return {
            "nodes": 0, "edges": 0,
            "density": 0.0, "top_entities": [],
        }

    top_entities = sorted(
        graph.degree(), key=lambda x: x[1], reverse=True
    )[:10]

    return {
        "nodes":        graph.number_of_nodes(),
        "edges":        graph.number_of_edges(),
        "density":      round(nx.density(graph), 4),
        "top_entities": [(name, deg) for name, deg in top_entities],
    }