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.
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.
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.
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))
)
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
Results summary
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.