← Back to list

From Candidate Retrieval to Fine-Grained Ranking (Part 3)

Part 2- https://medium.com/p/0894ab3cdb72

Utkarsh Mittal in Towards AI · 2026-04-17 04:53 · 1 claps · 9.5 min read paywalled
#machine-learning #artificial-intelligence #recommendation-system #recsys
Open on Medium ↗
Wiki topics: EVAL · Evaluation & Benchmarks ML · Machine Learning AI · AI · General EDU · Education & Learning

From Candidate Retrieval to Fine-Grained Ranking (Part 3)

Part 2- https://medium.com/p/0894ab3cdb72

In recommendation and ad-click systems, the signal rarely comes from individual features alone. What matters is combinations — a user who is young + mobile + evening behaves differently than the same age group on desktop at noon. These combinations are called feature interactions, and learning them well is the central challenge.

Logistic Regression (LR) was the industry workhorse for years. Fast, interpretable, easy to deploy. But it’s strictly linear — it can only learn interactions if you manually create them as new features (e.g. explicitly engineer an “age × device” column). With hundreds of raw features, the number of possible interactions explodes combinatorially. Human engineers had to guess which ones mattered, which was slow, brittle, and incomplete.

Factorization Machines (FM) were a big step forward — they could automatically learn pairwise (2nd order) interactions by decomposing the interaction weights into low-rank embeddings. But they were limited to 2nd order only. A signal like “young × mobile × evening” (3rd order) was out of reach without extensions that quickly became expensive.

Deep Neural Networks (DNN / MLP) seemed like the answer — stack enough layers with ReLU activations and the network should theoretically approximate any function, including high-order interactions. And they do learn something, but the key problem is that interactions are implicit and inefficient. The network has to accidentally stumble upon feature interactions through gradient descent across millions of parameters. There’s no structural guarantee it will find the right ones, and it wastes a lot of capacity doing so. In practice, vanilla DNNs often underperform on interaction-heavy tasks unless they’re very large.

Wide & Deep (Google, 2016) was an influential hybrid — a linear “wide” component (for memorization) combined with a deep component (for generalization). But the wide side still required manual feature engineering — a human had to decide which cross features to feed in. It didn’t solve the automation problem.

What DCN actually solves

DCN (Google, 2017) was designed to get the best of both worlds without the manual work:

The cross network (left path) addresses the FM/LR problem — it learns bounded, explicit polynomial feature interactions automatically, up to order L+1 where L is the number of cross layers. With 3 layers you get up to 4th-order interactions, and crucially the complexity grows only linearly with depth, not exponentially. No human needs to specify which interactions to look for.

The deep network (right path) addresses the DNN limitation — it handles the implicit, arbitrary non-linear patterns that don’t fit neatly into polynomial form.

Concatenating both means the model can separately optimize structured interaction learning and free-form non-linear learning, then combine them — rather than hoping a single DNN does both well at once.

The practical payoff: better accuracy at lower model size, and no feature engineering pipeline to maintain. For large-scale ad systems serving billions of predictions a day, that combination of accuracy and efficiency was a meaningful industrial advance.

Where We Are in the Pipeline

STAGE 2 — RANKING ← WE ARE HERE
├── FAISS gave us 1,000 candidates for C001
├── We scored them with Wide & Deep
│   └── BUT Wide side still needed manual cross features
│       "someone had to hardcode Denim_AND_evening"
├── DCN v2 is an upgrade to the ranking model
│   └── replaces manual crosses with automatic cross network
└── Same 1,000 candidates, same 100ms budget, better precision

Background — Why DCN v2 Was Needed

Problem 1 — Wide & Deep’s Wide Side Was Still Manual

Wide & Deep (2016):
    Deep side  → automatic ✅
    Wide side  → STILL manual ❌

Someone at your team had to sit down and write:
    "Denim_AND_evening"
    "mobile_AND_sale"
    "CA_AND_Denim_AND_evening"
    ...hundreds of these...

At e-commerce scale with 500 categories × 50M customers
× 10 context features = billions of possible crosses
Human brain cannot enumerate these               ❌

Problem 2 — Deep Network Learns Crosses Implicitly

Wide & Deep Deep side:
    MLP layers DO learn feature interactions
    BUT:
    ├── You can't see WHICH interactions it learned
    ├── You can't control the ORDER of interactions
    ├── It needs many layers and parameters to learn
    │   what could be captured more efficiently
    └── No guarantee it finds the RIGHT crosses

Problem 3 — Original DCN v1 (2017) Was Limited

DCN v1 (2017):
    Introduced cross layers                        ✅
    BUT used vector weights (less expressive)      ❌
    Could not learn feature-specific interactions  ❌
    Treated all features equally                   ❌

DCN v2 (2020):
    Upgraded to MATRIX weights per cross layer     ✅
    Each feature pair gets its own learned weight  ✅
    Much more expressive at same compute cost      ✅

The Solution DCN v2 Introduced

Replace the Wide side's manual crosses (DCN1 - in the Part2 series)
with a Cross Network that:

1. Takes the same x0 input (154 numbers)
2. Automatically computes ALL pairwise interactions (2nd order)
3. Then ALL triple interactions (3rd order)
4. Then ALL quartic interactions (4th order)
5. No human decides which crosses matter
6. Learned entirely from click data

The Math — Built Up Very Slowly

Before touching code, understand the cross layer formula:

x_l+1 = x0 × W_l(x_l) + x_l

Three operations. Let me explain each with 3 numbers first (not 154):

Suppose our entire input is just 3 features:
x0 = [hour=21,  price=79,  CTR=0.04]

Operation 1 — W_l(x_l): Transform current layer input

W is a [3×3] weight matrix:

              out1   out2   out3
in1 (hour)    0.1    0.2    0.3
in2 (price)   0.4    0.1    0.2
in3 (CTR)     0.3    0.4    0.1

W(xl) output1 = (0.1×21) + (0.4×79) + (0.3×0.04)
              = 2.1 + 31.6 + 0.012
              = 33.71

W(xl) output2 = (0.2×21) + (0.1×79) + (0.4×0.04)
              = 4.2 + 7.9 + 0.016
              = 12.12

W(xl) output3 = (0.3×21) + (0.2×79) + (0.1×0.04)
              = 6.3 + 15.8 + 0.004
              = 22.10

W(xl) = [33.71, 12.12, 22.10]

Operation 2 — x0 × W_l(x_l): Element-wise multiply with ORIGINAL input

x0    = [21,    79,    0.04  ]   ← ORIGINAL input, never changes
W(xl) = [33.71, 12.12, 22.10]   ← transformed current layer

Element-wise multiply (NOT matrix multiply):
position 1: 21    × 33.71 = 707.91   ← hour    × (combo of all features)
position 2: 79    × 12.12 = 957.48   ← price   × (combo of all features)
position 3: 0.04  × 22.10 = 0.884    ← CTR     × (combo of all features)

result = [707.91, 957.48, 0.884]

THIS IS THE CROSS.
Each original feature is now multiplied by a learned combination
of ALL other features → automatic pairwise interactions

Operation 3 — + x_l: Residual (blue line in diagram)

x0 × W(xl) = [707.91, 957.48, 0.884]
xl          = [21,     79,     0.04 ]   ← add back current layer input

x_l+1      = [707.91+21, 957.48+79, 0.884+0.04]
           = [728.91,    1036.48,   0.924      ]

Why add xl back?
→ Preserves the signal from previous layer
→ Prevents information loss as we go deeper
→ Makes training more stable (same idea as ResNet)

Now With Real E-commerce Data — All 154 Features

Step 1 — Start With Just ONE Row

Before building the full matrix, understand what ONE row looks like.

C001 viewing P042 (Denim Slim Jeans, $79) at 9pm on mobile:

ONE ROW = 154 numbers side by side:

← dense (10) →← user embedding (64) →← item embedding (64) →← cat (16) →

[32, 85, 0.04, 21, 79, 7, 0.22, 0.18, 8.4, 1, | 0.8, 0.2, 0.9, ...(61 more), 0.3, | 0.7, 0.3, 0.8, ...(61 more), 0.6, | 0.5, 0.1, ...(14 more), 0.4]

Total: 10 + 64 + 64 + 16 = 154 numbers

Now let’s zoom into each section:

Col  Feature               Value   Where it comes from
───────────────────────────────────────────────────────────────
[0]  age                   32      C001's profile table
[1]  avg_order_value       85      C001's order history → mean($)
[2]  item_avg_ctr          0.04    P042's impression logs → clicks/views
[3]  hour_of_day           21      current session timestamp
[4]  item_price            79      P042's product catalog
[5]  days_since_purchase   7       C001's last order date vs today
[6]  item_return_rate      0.22    P042's return logs → returns/purchases
[7]  user_return_rate      0.18    C001's return history → returns/purchases
[8]  session_length        8.4     current session duration in minutes
[9]  device_mobile         1       current device (1=mobile, 0=desktop)
As a row fragment:
position: [0]  [1]  [2]    [3]  [4]  [5]  [6]   [7]   [8]  [9]
value:     [32, 85,  0.04,  21,  79,  7,   0.22, 0.18,  8.4, 1  ]

Section A — Dense Features (Columns 0–9)

These are raw numbers directly about C001 and P042:

The Starting Input x0

x0 for C001 viewing P042 (Denim, $79) at 9pm on mobile:

DENSE FEATURES (positions 0-9):
[0]  age                  = 32
[1]  avg_order_value      = 85
[2]  item_avg_ctr         = 0.04
[3]  hour_of_day          = 21
[4]  item_price           = 79
[5]  days_since_purchase  = 7
[6]  item_return_rate     = 0.22
[7]  user_return_rate     = 0.18
[8]  session_length       = 8.4
[9]  device_mobile        = 1

USER EMBEDDING (positions 10-73):
[10] user_dim_1  = 0.8    ← encodes C001's Denim affinity
[11] user_dim_2  = 0.2    ← encodes C001's price sensitivity
[12] user_dim_3  = 0.9    ← encodes C001's evening activity
...
[73] user_dim_64 = 0.3

ITEM EMBEDDING (positions 74-137):
[74]  item_dim_1  = 0.7   ← encodes P042's Denim-ness
[75]  item_dim_2  = 0.3   ← encodes P042's price tier
[76]  item_dim_3  = 0.8   ← encodes P042's popularity
...
[137] item_dim_64 = 0.6

CATEGORY EMBEDDING (positions 138-153):
[138] cat_dim_1  = 0.5    ← encodes Denim category
...
[153] cat_dim_16 = 0.4

x0 shape: [1000 × 154]   ← all 1,000 candidates stacked

Cross Layer 1–2nd Order (Pairwise Interactions)

class CrossLayer(nn.Module):
    def __init__(self, input_dim: int):
        super().__init__()
        self.W = nn.Linear(input_dim, input_dim, bias=True)
self.W = nn.Linear(154, 154)
Weight matrix shape: [154 × 154]
= 23,716 parameters per cross layer
Each of 154 output dimensions looks at ALL 154 input dimensions
def forward(self, x0: torch.Tensor, xl: torch.Tensor) -> torch.Tensor:
        return x0 * self.W(xl) + xl

At Cross Layer 1, x0 = xl = our 154-number vector:

Step 1 — self.W(xl):
    Weight matrix [154×154] transforms xl

    Output position 0 (will interact with age=32):
    = w0,0×32 + w1,0×85 + w2,0×0.04 + w3,0×21 + w4,0×79
    + w5,0×7  + ... + w10,0×0.8 + w11,0×0.2 + ...
    + w74,0×0.7 + ... + w138,0×0.5 + ...
    = 39.21   ← weighted combination of ALL 154 features

    Output position 3 (will interact with hour=21):
    = w0,3×32 + w1,3×85 + w2,3×0.04 + w3,3×21 + ...
    = 28.43   ← different weights, different combination

    W(xl) = [39.21, 41.83, 30.60, 28.43, 19.62, ...]
             ↑      ↑      ↑      ↑
             will   will   will   will
             cross  cross  cross  cross
             age    order  CTR    hour

Step 2 — x0 * self.W(xl) (element-wise):
    x0    = [32,    85,    0.04,  21,    79,    ...]
    W(xl) = [39.21, 41.83, 30.60, 28.43, 19.62, ...]

    position 0: 32    × 39.21 = 1254.7  ← age    × (ALL features combined)
    position 1: 85    × 41.83 = 3555.6  ← order  × (ALL features combined)
    position 2: 0.04  × 30.60 = 1.224   ← CTR    × (ALL features combined)
    position 3: 21    × 28.43 = 597.0   ← hour   × (ALL features combined)
    position 4: 79    × 19.62 = 1550.0  ← price  × (ALL features combined)
    ...
    position 10: 0.8  × W10   = ?       ← user_dim1 × (ALL features)
    position 74: 0.7  × W74   = ?       ← item_dim1 × (ALL features)

    These are 2nd order crosses — every feature crossed with
    a learned combination of every other feature

Step 3 — + xl (residual):
    [1254.7, 3555.6, 1.224, 597.0, 1550.0, ...]
  + [32,     85,     0.04,  21,    79,     ...]
  = [1286.7, 3640.6, 1.264, 618.0, 1629.0, ...]

x1 = [1286.7, 3640.6, 1.264, 618.0, 1629.0, ...]
Shape: [1000 × 154]   ← SAME shape as input

What x1 now encodes:

x1[0] = 1286.7  encodes: age × (hour+price+CTR+user_embed+item_embed+...)
x1[3] = 618.0   encodes: hour × (age+price+CTR+user_embed+item_embed+...)
x1[10]= ?       encodes: user_dim1 × (age+hour+price+item_embed+...)

In plain English:
"How does C001's age interact with everything else about this moment?"
"How does 9pm interact with everything else about C001 and P042?"
"How does C001's Denim affinity interact with P042's features?"

Cross Layer 2–3rd Order (Triple Interactions)

# Second call: layer(x0, x1) → x2
x_cross = layer(x0, x1)
Now xl = x1 (which already contains 2nd order interactions)
x0 = STILL the original [32, 85, 0.04, 21, 79, ...] ← never changes

Step 1 — self.W(x1):
    x1 already encodes "age × everything", "hour × everything" etc.
    W transforms x1:

    Output position 3 (will interact with hour=21):
    = w0,3×1286.7 + w1,3×3640.6 + w2,3×1.264 + w3,3×618.0 + ...
    = now mixing 2nd order interactions together
    = 8421.3   ← combination of combinations

Step 2 — x0 * self.W(x1):
    position 3: 21 × 8421.3 = 176,847.3
    ← hour × (age×everything + price×everything + ...)
    ← this IS a 3rd order interaction:
       hour × age × (everything)
       hour × price × (everything)

Step 3 — + x1 (residual):
    [..176847.3..] + [..618.0..] = [...177465.3...]

x2 shape: [1000 × 154]   ← still same shape

What x2 encodes:
    "C001's age × hour=9pm × P042's price"
    "C001's Denim affinity × hour=9pm × device=mobile"
    "item_CTR × user_avg_order × item_price"

Deep Network Path — Right Side of Diagram

layers, in_dim = [], self.input_dim   # 154
        for h in deep_hidden_dims:            # [256, 128]
            layers += [nn.Linear(in_dim, h), nn.ReLU(), nn.Dropout(0.1)]
            in_dim = h
        self.deep = nn.Sequential(*layers)
SAME x0 goes into deep network simultaneously:
x0 [1000×154] → Linear(154→256) → ReLU → Dropout
              → Linear(256→128) → ReLU → Dropout
              → x_deep [1000×128]

This is identical to Wide & Deep's deep side.
Learns IMPLICIT interactions — non-linear, harder to interpret
but captures patterns the cross network might miss.

Cross network = explicit, controlled, polynomial
Deep network  = implicit, flexible, non-linear
Together      = best of both                        ✅

Concatenation and Final Score

combined = torch.cat([x_cross, x_deep], dim=1)
        return torch.sigmoid(self.output(combined)).squeeze(1)
x_cross: [1000 × 154]  ← explicit 4th order interactions
x_deep:  [1000 × 128]  ← implicit non-linear interactions

torch.cat → [1000 × 282]

self.output = nn.Linear(282, 1):
    282 numbers → single score per candidate

sigmoid → P(click) for all 1,000 candidates:

Candidate  Cross score  Deep score  Combined  P(click)  Rank
P042       strong Denim  high intent  0.84      0.84      #2
P107       weak cross    medium       0.81      0.81      #4
P334       strong cross  high intent  0.91      0.91      #1  ← cross network
P088       weak cross    low intent   0.43      0.43      #48    found this!

DCN v2 vs Wide & Deep — Final Comparison

WIDE & DEEP:
    Wide side:
        Input:  manually engineered "Denim_AND_evening" strings
        Crosses: only what humans thought to build
        New feature: re-engineer all crosses manually  ❌
        Max order: usually 2nd order (pairs only)      ❌
        Parameters: ~10 (just dense features)

    Deep side:
        154 → 256 → 128 → 64 → 1
        Implicit crosses only

DCN v2:
    Cross network:
        Input:  raw x0 (154 numbers)
        Crosses: ALL combinations learned automatically ✅
        New feature: just add to x0, auto-discovered   ✅
        Max order: 4th order (quartic)                 ✅
        Parameters: 3 × 154 × 154 = 71,148

    Deep network:
        154 → 256 → 128
        Same implicit crosses

    Key win: adding a new feature (e.g. user_loyalty_score)
    Wide & Deep: re-engineer all manual crosses         ❌
    DCN v2:      just add to x0, cross network
                 automatically finds new interactions   ✅

메타데이터
post_id
339cc4c166cb
slug
from-candidate-retrieval-to-fine-grained-ranking-part-3-339cc4c166cb
url
https://pub.towardsai.net/from-candidate-retrieval-to-fine-grained-ranking-part-3-339cc4c166cb
canonical_url
https://pub.towardsai.net/from-candidate-retrieval-to-fine-grained-ranking-part-3-339cc4c166cb
author_url
https://medium.com/@mittalutkarsh
status
ok
fetched_at
2026-07-13 10:53:13