Writing › NODES 2026

Graph-Aware Recommendations with Neo4j GDS and Random Forest

· Martyns Nwaokocha and Kamil Yazigee · Session: Data Intelligence, 12 November, 15:30 GMT+1

At NODES 2026 Kamil Yazigee and I are presenting our production recommendation engine for enterprise technology products, built entirely inside Neo4j GDS. This post is a full technical walkthrough of everything in the talk: the graph design, the feature engineering, the hard negative sampling problem and how we solved it, the model, the calibration, and how we measure real-world ranking quality.

The problem: recommending technology products to enterprise buyers

IDC publishes Technographics data: which technology products enterprise companies actually use. The dataset is large and dense. The challenge we set out to solve is recommendation: given a company we know, which products should we suggest next?

This is a classic enterprise recommendation problem with two structural difficulties. First, the cold-start problem: many companies have thin purchase histories. Second, the long-tail problem: a handful of dominant products (Google Analytics, Microsoft 365, Amazon Web Services) account for the majority of adoptions, while thousands of niche products sit in a very long tail. A naive model will learn to recommend the popular products to everyone. That is not useful.

The talk demonstrates how to use the structure of the purchase graph itself to produce recommendations that are both accurate and diverse.

The knowledge graph: a bipartite co-adoption network

The foundation is a bipartite graph. One node type represents companies (Technographics); the other represents technology products (Unique_Product). An edge between them means that company has adopted that product. The edge carries a product_count weight: how many distinct deployments IDC has observed.

COMPANIES Technographics PRODUCTS Unique_Product Acme Corp Apex Inc BridgeCo 49,200+ Google Analytics Microsoft 365 Amazon EC2 55 products CONTEXT product_count weight 49,241 nodes 258,300 CONTEXT relationships density: 0.000107
Figure 1. The bipartite knowledge graph. Companies (left) connect to products (right) via weighted CONTEXT relationships representing observed co-adoption. Density of 0.000107 is typical for enterprise co-adoption graphs: most company-product pairs are non-edges.

In the scoped dataset the graph contains 49,241 nodes and 258,300 CONTEXT relationships (density: 0.000107). We scoped to US-headquartered companies and 55 technology products spanning the Amazon Web Services, Google Cloud, and Microsoft product families: the products where IDC Technographics signal is richest and purchase patterns are most interpretable.

Feature engineering inside the projected graph

All feature engineering happens inside a GDS projected graph called TechnologyAdoption. We mutate three sets of node properties: FastRP embeddings, degree centrality, and PageRank. A fourth property, the SIC code one-hot encoding, is derived from the database before projection and carried into the graph as a node feature.

FastRP embeddings

FastRP (Fast Random Projection) produces a low-dimensional embedding for each node by projecting sparse random walk statistics into a dense vector space. The key property for a bipartite co-adoption graph: two company nodes will have similar embeddings if they tend to adopt similar products, because their walk statistics will overlap. Equivalently, two product nodes will be similar if the same types of companies adopt them.

We project with undirectedRelationshipTypes: ['CONTEXT']. Making the edges undirected is important here: it allows the random walk to cross from the company partition into the product partition and back, so the embedding for a company can absorb signal from the full co-adoption neighborhood of its products, not just its direct connections.

CALL gds.fastRP.mutate('TechnologyAdoption', {
  relationshipTypes: ['CONTEXT'],
  nodeLabels: ['Technographics', 'Unique_Product'],
  mutateProperty: 'frp',
  embeddingDimension: 128,
  normalizationStrength: -1,
  randomSeed: 42
})

normalizationStrength: -1 applies L2 normalisation, which places all embeddings on the unit hypersphere. This is a prerequisite for the element-wise product feature construction that feeds the Random Forest (explained below).

Degree and PageRank

Degree centrality (weighted by product_count) captures how many product adoptions a company has, and how widely adopted a product is across the market. PageRank captures the same structural information but with recursive weighting: a company that adopts products also adopted by other well-connected companies scores higher. Both are mutated into the projected graph and written back to the Neo4j database.

SIC code one-hot encoding

Standard Industrial Classification (SIC) code is a firmographic property of each company. It encodes industry vertical: a software company will have different co-adoption patterns from a healthcare provider or a manufacturer. We compute a one-hot encoding of SIC codes from the TRAIN set of companies using GDS:

// Derive unique SIC codes from TRAIN set companies
// then one-hot encode each Technographics node
SET a.SIC_OHE = gds.alpha.ml.oneHotEncoding(SIC_CODES,
    [coalesce(toInteger(a.SIC_CODE), -1)])

// Products get a uniform vector (SIC is not defined for products)
SET a.SIC_OHE = [i IN range(0, Len_SIC_CODES - 1) | 1]

Products receive a uniform vector of ones, not a zero vector. This ensures that the dot product of a company SIC vector and a product's uniform vector extracts the company's SIC embedding intact, without introducing spurious zeroes into the projected graph calculations.

The negative sampling problem

Link prediction requires both positive examples (edges that exist) and negative examples (edges that do not). Generating negatives for a bipartite co-adoption graph is not trivial.

The graph has a power-law degree distribution: a few products (Google Analytics, Microsoft 365, Amazon Web Services) have very high degree because almost every company in the dataset has adopted them. The rest of the product catalog forms a long tail. In a graph with density 0.000107, the vast majority of company-product pairs are non-edges. A purely random negative sample will almost exclusively contain pairs involving long-tail products, because those products have fewer existing edges to exclude.

A model trained on random negatives will solve the wrong problem. It will learn to distinguish popular-product pairs (positive) from obscure-product pairs (negative), which is easy. When deployed, it will recommend the same popular products to every company, which is useless.

Products (ranked by degree, high to low) Degree (number of companies) Product node degree distribution Google Analytics max_degree: 22,225 long tail naive negatives rarely land here 22k 11k 0
Figure 2. Power-law degree distribution across the 55 product nodes. A random negative sample overwhelmingly lands in the long tail, where negatives are trivially easy. The model then learns to recommend the same popular products to everyone.

Hard negative sampling via constrained random walks

The solution is to make the negative samples structurally hard: the negative product should be plausible given the company's co-adoption neighborhood, but genuinely not adopted by that company. We use gds.randomWalk.stream for this.

For each positive (company, product) pair in TRAIN, we run constrained random walks starting from the positive product. Because the graph is bipartite (company-product-company-product...) a walk of length 5 starting from a product node follows the pattern: product → company → product → company → product. Nodes at positions 2 and 4 in the walk are products that are plausibly co-adopted with the starting product: they share at least one common customer.

Hard negative sampling: walkLength=5 on bipartite graph P_start positive product company pos.1 P_cand_1 pos. 2 company pos.3 P_cand_2 pos. 4 hop 1 hop 2 hop 3 hop 4 Guard filter (both candidates) NOT EXISTS { (company)-[:Unique_BUYS]->(candidate) } AND candidate_1 ≠ candidate_2 25 walks per source, 1 negative per positive sampled by APOC shuffle
Figure 3. Hard negative sampling via constrained random walk (walkLength=5, 25 walks per positive source node). Positions 2 and 4 in the walk are product nodes that share at least one common customer with the anchor product, making them structurally plausible negatives. Guard filters ensure the candidate is not already bought by the company.

The walk is run using apoc.periodic.iterate for batched execution. For each company in TRAIN, we collect all its positive product nodes as sources, run 25 walks per source of length 5, extract positions 2 and 4, apply guard filters, and use APOC shuffle to sample one negative per positive:

CALL gds.randomWalk.stream('NegativeSampling_TRAIN', {
  relationshipTypes: ['CONTEXT'],
  nodeLabels: ['Technographics', 'Unique_Product'],
  sourceNodes: sourceNodes,   // positive product IDs for this company
  walkLength: 5,
  walksPerNode: 25,
  randomSeed: 42,
  concurrency: 4
})
YIELD nodeIds, path

After sampling: 16,120 positive pairs in TRAIN, 16,120 hard negative pairs in NEG_TRAIN. TRAIN and TEST sets are strictly non-overlapping (verified by inner join returning zero rows).

Stratified resampling to correct power-law skew

Even with hard negatives, the training set retains the power-law skew of the original graph: pairs involving high-degree products dominate both the positive and negative classes. A model trained on this raw balanced set will still produce predictions concentrated on popular products.

We apply a stratified resampling scheme. For each pair, the selection probability is 1 / product_node_degree. This means a pair involving a product adopted by 10,000 companies is selected with probability 1/10,000. Pairs are drawn with replacement (Bernoulli trials) and accumulated until the resampled set size equals the original set size. The result is a training set where long-tail products are represented proportionally to the number of distinct degree levels, not to their raw frequency.

Critically, this resampling is applied independently to the positive and negative subsets, so the 50/50 positive ratio is preserved. The TRAIN/TEST boundary is also preserved: no TEST pairs enter the resampled TRAIN.

The model: Random Forest Regressor on element-wise embedding products

The feature for each (company, product) pair is the element-wise product of their two 128-dimensional FastRP embeddings: a 128-dimensional vector. Because both embeddings are L2-normalised, the element-wise product captures the per-dimension alignment between the two nodes' structural positions in the graph. Dimensions where company and product have the same sign and similar magnitude contribute large positive values; dimensions where they differ contribute near-zero values.

# From utils.py: process_data()
df['embedding'] = df.apply(
    lambda row: row['company_node_embedding'] * row['product_node_embedding'],
    axis=1
)
# Expand to 128 columns: emb_0, emb_1, ..., emb_127
cols = ['emb_' + str(i) for i in range(0, L)]
df[cols] = df['embedding'].tolist()

We use a RandomForestRegressor, not a classifier. The target is a binary label (1 for positive, 0 for negative), but treating it as a regression problem lets the out-of-bag (OOB) prediction produce a continuous probability estimate without a separate validation split. The OOB score is computed on samples not included in each tree's bootstrap draw, which gives an unbiased estimate of generalisation performance.

Hyperparameter search with Optuna TPE

We search over three hyperparameters using Optuna's Tree-structured Parzen Estimator (TPE) sampler: number of trees (1 to 100), maximum depth (1 to 32), and max_features (fixed to sqrt for 128 dimensions). The optimisation objective is OOB AUPRC (area under the precision-recall curve), computed on OOB predictions after each trial's model is fitted.

Crucially, there is no separate validation set. The OOB mechanism is the validation. This is a deliberate choice: with a relatively small TRAIN set (approximately 32,000 pairs after resampling), holding out additional data for validation would substantially reduce the training set. The OOB estimate is well-calibrated for Random Forests and avoids this cost.

# Winning hyperparameters (C_RandomForest_Model.ipynb, 100 TPE trials)
# n_estimators: 177, max_depth: 26, max_features: 'sqrt'
# OOB AUPRC: 0.9732 (97.3%)

rf = RandomForestRegressor(
    max_depth=26,
    n_estimators=177,
    max_features='sqrt',
    random_state=42,
    oob_score=True,
    n_jobs=-1
)

Platt re-calibration: restoring the population prior

After training, the model's raw predictions are calibrated to a 50% positive rate, because that is the rate in the balanced training sample. But the true positive rate in the population is approximately 5.5%: on average, a US company in the dataset has adopted 5.5% of the 55 products in scope. Scores calibrated to a 50% prior will massively overstate the probability of adoption for any individual product.

Platt scaling corrects this using the ratio between the training prior and the population prior:

# target_positive_rate  ~= 0.055 (measured from the full CONTEXT graph)
# sampled_positive_rate ~= 0.500 (the balanced training sample)

ratio_1 = target_positive_rate / sampled_positive_rate
ratio_2 = (1 - target_positive_rate) / (1 - sampled_positive_rate)

precision_calibrated = (
    ratio_1 * precision /
    (ratio_1 * precision + ratio_2 * (1 - precision))
)
Before calibration 50% baseline Recall Precision AUPRC measured against 50% prior After Platt calibration 5.5% gap = useful lift Recall Precision Precision reflects real-world adoption rate Platt
Figure 4. Platt re-calibration lowers the baseline from 50% (sampled prior) to 5.5% (population prior). The same PR curve now reports precision values that reflect actual adoption probability, making the scores actionable for ranking and threshold decisions.

After calibration, a predicted score of 0.15 means "this company is approximately three times more likely to adopt this product than a randomly selected company." That is a meaningful, actionable number. Before calibration, the same score would be expressed relative to a 50% prior, which would understate the lift dramatically.

Evaluation: Hits@K over the full score matrix

AUPRC is a threshold-free evaluation of the model's discriminative ability. But the product of this system is a ranked list of recommendations per company, not a binary classification. The metric that matches the actual task is Hits@K: for each company in the TEST set, score all 55 products, rank them, and measure what fraction of companies have their true positive product in the Top-K recommendations.

The evaluation queries the full company × product score matrix from Neo4j, applies the fitted model to every pair, and reshapes into a per-company ranking:

MATCH (c:Technographics) WHERE id(c) IN company_node_ids
MATCH (p: Unique_Product) WHERE p.id IN produkty
ORDER BY id(c), p.id
RETURN id(c), c.frp, c.pageRank, c.degree,
       id(p), p.id, p.frp, p.pageRank, p.degree

The resulting score matrix is (num_test_companies × 55). For each company, the column corresponding to its true positive product is the signal. Hits@1 asks whether the model puts the right product at rank 1. Hits@5 asks whether the right product appears anywhere in the top 5 recommendations. A random baseline achieves Hits@K = K / 55.

The complete pipeline

01 Graph projection 02 GDS feature engineering 03 Graph-aware sampling 04 Stratified resampling 05 RF scoring OOB optim. 06 Platt calibration 07 Hits@K evaluation FastRP,deg,PR SIC_OHE,frp walkLength=5 1/degree weight 97.3% AUPRC OOB 5.5% prior 55 products
Figure 5. The complete pipeline. Steps 1 to 4 happen inside Neo4j GDS. Steps 5 and 6 happen in Python with scikit-learn. Step 7 queries the score matrix back from Neo4j and evaluates ranking quality.

Results summary

97.3% OOB AUPRC after Optuna TPE search, 100 trials
5.5% Population prior restored by Platt calibration from 50% sampled prior
258,300 CONTEXT edges 49,241 nodes; density 0.000107
128 Embedding dimensions FastRP, undirected projection, L2 normalised

What you will leave with

The session is structured as a complete, reproducible blueprint. By the end of the 30 minutes you will have seen:

  • How to build a bipartite knowledge graph projection in GDS suitable for link prediction
  • How FastRP captures co-adoption signal on an undirected bipartite graph, and why L2 normalisation matters before element-wise feature construction
  • Why naive negative sampling fails on power-law graphs, and how constrained random walks produce structurally hard negatives
  • The stratified resampling technique that corrects degree skew without destroying the train-test boundary
  • Why a regressor trained with OOB scoring can substitute for a held-out validation set in link prediction
  • How Platt scaling reconnects model scores to real-world adoption rates, and why this step is not optional if scores will be used for ranking or thresholding
  • How Hits@K over the full score matrix measures actual recommendation quality rather than pairwise discrimination

The codebase is written in Python using Neo4j GDS Python client, scikit-learn, and Optuna. The pipeline is independent of the specific product catalog and reusable for any bipartite co-adoption or co-purchase graph.

About the speakers

Kamil Yazigee and I are both on the Data Science team at IDC (International Data Corporation), where we build graph-based data products on top of the IDC Knowledge Platform. Kamil led the GDS feature engineering and sampling pipeline; I led the model training, calibration and evaluation. The talk represents production work, not a toy example.

If you are attending NODES 2026 and want to discuss this further, or if you have a recommendation, knowledge graph or graph machine learning problem you want to talk through, reach out in advance.

Ready to start?

Tell me what you are trying to build. I will reply within one business day.

Get in touch