◂ feed-research2026-08-29 빌드

멀티태스크 랭킹 — 참여 확률 여러 개를 하나의 점수로

최종 갱신: 2026-08-09

랭커가 "이 유저가 이 아이템을 좋아할까"를 스칼라 하나로 예측하지 않고, 행동 종류별로 확률을 따로 예측한 뒤 하나의 점수로 합쳐서 정렬하는 구조.

⚠ 이 노트의 Meta system card 기반 주장(예측 헤드 개수 등)은 전부 2026-08-09 열람 스냅샷이다 — 전체 주의는 ../services/facebook.md의 "system card 인용의 유효기간" 절에 있다.

score = Σ_k  w_k · P(action_k | user, item)

단, 이 가중합은 여러 결합 형태 중 하나일 뿐이다. Meta는 같은 회사 안에서 지면마다 덧셈형·곱셈형·곱셈 페널티를 각각 다른 글에서 수식으로 공개했다(아래 표). "멀티태스크 랭킹 = 가중합"은 관찰이 아니라 가정이다.

여기서 진짜 설계 결정은 네 가지다. 어떤 행동을 헤드로 둘 것인가, 어떤 형태로 결합할 것인가, 음수 행동을 어떻게 섞을 것인가, 가중치 w_k를 어디서 가져올 것인가.

왜 이 구조인가

케이스: Meta — 결합 형태가 지면마다 다르다

Facebook이 공개한 결합은 선형 하나인데, Instagram은 세 개를 서로 다른 문제에 쓴다고 공개했다. 한 회사 안에서 최소 3종이다.

지면 형태 수식 (별도 표기 없으면 verbatim) 연도
Facebook Feed 덧셈 Vijt = wijt1·Yijt1 + wijt2·Yijt2 + … + wijtk·Yijtk 2021
Instagram Suggested Posts 곱셈 Value(Post) = (probability_like)^weight_like * (1- probability_not_interested)^weight_see_less 2020
Instagram Explore 덧셈 + 음수항 Expected Value = W_click * P(click) + W_like * P(like) – W_see_less * P(see less) + etc. 2023
Instagram Notifications 곱셈 페널티 verbatim 아님 — 원문은 LaTeX 이미지다. Score(c) = R(c) × D(c), D(c) = ∏_{i=1..m} (1 - w_i · p_i(c)) 는 그 이미지를 옮긴 것 [관찰] 2026-08-09. 곱셈 구조와 R·D 정의는 본문 산문이 뒷받침 [확인]feed-diversity.md 2025

[확인] 순서대로 — How machine learning powers Facebook's News Feed ranking algorithm(2021) / Designing a Constrained Exploration System(2020) / Scaling the Instagram Explore recommendations system(2023) / A New Ranking Framework for Better Notification Quality on Instagram(2025)

지면별 맥락은 facebook.md · instagram.md. 여기서는 형태 비교만 한다.

읽는 법

X와 정반대인 지점 — 음수를 곱하느냐

같은 문제에 두 회사가 반대 방향으로 갔다.

[추정] X는 음수 헤드가 순위를 흔드는 것을 막는 데 두 세대를 썼고, Instagram은 그 흔들림(붕괴)을 오히려 기능으로 쓴다. 코드에도 블로그에도 이유는 없다.

가중치를 어디서 가져오는가 — 회사 간 진짜 분기점

주체 공개된 방법 근거
Facebook 설문과 각 이벤트의 correlation으로 정한다 [확인] 2021
Instagram offline replay/offline tuning + Bayesian optimization [확인] 2020, 2023
X 방법 미공개. 주입 지점(FeatureSwitch 파라미터 / query feature)과 값 범위만 코드에 있다 [확인] 공개 레포(c54bec0·xai-org/x-algorithm) 전수 — 값은 런타임 주입이라 코드에 없다

헤드 수 19~22 vs ~10 — 비교하면 안 되는 두 숫자

출처 헤드 수 출처 종류
X Phoenix 공개 체크포인트 19 소스코드
X 2026 서빙 코드 22 소스코드
X 2026 VMRanker proto 복사본 21 소스코드
X 2025 Navi / Phoenix 15 / 13 소스코드
Facebook Feed 카드 10 system card
Facebook Feed Recommendations 카드 10 system card
Threads Feed 카드 10 system card
Instagram 각 카드 총 개수 미확정 system card

[확인] X 쪽은 위 설계 축 3의 파일 인용. Meta 쪽은 각 system card — fb-feed, fb-feed-recommendations, ig-threads-feed (2023~, 규제 대응 문서)

"X는 헤드가 두 배"라고 쓰면 안 된다. 두 숫자의 성격이 다르다.

부정 헤드의 존재 여부는 Meta 안에서도 갈린다: Instagram은 수식 안에 항으로 박아 공개했고(– W_see_less·P(see less)), Facebook은 본문 서술로만 밝히고, Threads 카드에는 부정 예측 헤드가 하나도 없다. [관찰] 2026-08-09 (ig-threads-feed 카드 본문 1건) X 2026은 5개(not_interested/block/mute/report/not_dwelled)다. [확인]

케이스: X — 설계 축 6가지

1. 가중치는 코드에 없다

X는 세 세대 모두 가중치를 FeatureSwitch(런타임 설정) 로 뺐다. 레포에 있는 건 default 값이고, 프로덕션 값은 레포 밖에 있다.

세대 파라미터 수 범위 레포상 default
2023 Scala 17 제각각 — 9개는 0..100, 부정 4종은 음수 전용(Report −20000..0 포함), GoodClick류 상한 1,000,000 10개 1.0, 7개 0.0
2025 Scala 30 2023과 같은 골격 (Report −20000..0 동일) 전부 0.0
2026 Rust 22 미공개 (params 모듈 자체가 없음) 미공개

(⚠ 이전 판은 2023을 "0.0~100.0, 음수 표현 불가"로 적었다 — 원문 대조 결과 오류. 음수 전용 범위 4종이 2023 ef4c5eb부터 있었다. 2026-08-09 수정. → x.md 가중치 절)

[확인] 2023·2025 — ScoredTweetsParam.scala:151(2023), home-mixer/.../param/HomeGlobalParams.scala:788-978(2025). 2026 — home-mixer/scorers/ranking_scorer.rs:43-66

2025에서 default가 전부 0.0인 건 "좋아요 가중치가 0"이라는 뜻이 아니다. "가중치는 전부 런타임 주입이고 레포에는 없다"는 뜻이다. 이걸 잘못 읽으면 정반대 결론이 나온다.

범위 자체에 정책이 박혀 있는 경우는 있다. ReportParam은 min -20000.0 / max 0.0, WeakNegativeFeedbackParam·StrongNegativeFeedbackParam은 min -1000.0 / max 0.0음수 전용으로 잠겨 있다. 운영자가 실수로도 양수를 못 넣는다. 이 경계는 2023부터 동일하다(2025 신설이 아니다). [확인]HomeGlobalParams.scala:924-946, 2023 72eda9a 대조

→ 널리 인용되는 X 가중치 표(fav 0.5, retweet 1.0, reply 13.5, report −369.0 …)의 출처와 신뢰도는 x.md의 "가중치 — 무엇이 공개됐고 무엇이 아닌가" 참조. 요약하면 2023-04-05 시점 ML 레포 README 한 곳뿐이고, 서빙 레포 31개 커밋 어디에도 나온 적 없다.

2. 음수 헤드를 곱셈에 넣느냐 마느냐

가장 갈리는 지점이다. X 2025는 곱하지 않는다.

// 양수 가중치
combinedScore + score * weight

// 음수 가중치 — 임계값을 넘으면 상수 페널티만
normScore = if (maxHeadScore == 0.0) 0.0 else score / maxHeadScore
if (normScore > thresholdNormalized || score > thresholdConstant)
  combinedScore + weight     // score를 곱하지 않는다
else combinedScore

[확인]home-mixer/.../util/RerankerUtil.scala:106-121

이유는 [추정]이다: 음수 헤드는 확률이 작고 노이즈가 커서 곱셈으로 넣으면 점수가 요동친다. 임계값 통과 여부만 보면 계단 함수가 되어 안정적이다. 코드에 이유는 안 적혀 있다.

정규화 기준이 요청 내 배치 상대값(score / maxHeadScore, 그 요청에 들어온 후보 전체의 헤드별 최댓값)이라는 게 중요하다. 같은 포스트라도 같이 채점된 후보 구성이 바뀌면 음수 페널티 발동 여부가 바뀐다. [확인]RerankerUtil.scala:43-65

전환 중인 흔적도 있다. EnableNegSectionRankingParam을 켜면 combinedScore + weight * (1.0 min (score + 0.1))로 바뀌는데, 바로 위에 "This should be shipped and cleaned as soon as possible" 주석이 달려 있다. [확인]RerankerUtil.scala:115-118

주의: 레포 default 상태에서는 음수 헤드가 아무 감점도 만들지 않는다. 관련 boolean 4개(NormalizedNegativeHead, ConstantNegativeHead, UseWeightForNegHeadParam, EnableNegSectionRankingParam)가 전부 false다. [확인]HomeGlobalParams.scala:545-573

2026 Rust는 방식을 바꿔서 음수 헤드도 그냥 곱한다. 대신 총점이 음수가 되면 통째로 압축한다.

fn offset_score(combined_score: f64, w: &ScoringWeights) -> f64 {
    if w.total_sum == 0.0 { combined_score.max(0.0) }
    else if combined_score < 0.0 {
        (combined_score + w.negative_sum) / w.total_sum * NEGATIVE_SCORES_OFFSET
    } else { combined_score + NEGATIVE_SCORES_OFFSET }
}

[확인]ranking_scorer.rs:175-183

이 함수는 2025 Scala 함수의 직역이다. 세 갈래 분기가 순서까지 한 줄씩 대응한다 [확인] RerankerUtil.scala:130-135:

if (modelWeightsSum == 0) combinedScoreSum.max(0.0)
else if (combinedScoreSum < 0)
  (combinedScoreSum + negativeModelWeightsSum) / modelWeightsSum * Epsilon
else combinedScoreSum + Epsilon

대응: modelWeightsSumw.total_sum, negativeModelWeightsSumw.negative_sum, EpsilonNEGATIVE_SCORES_OFFSET.

그래서 미공개 상수 하나를 되찾을 수 있다. 2026 NEGATIVE_SCORES_OFFSETcrate::params에 있어 값이 안 보이지만, 2025 대응물은 val Epsilon = 0.001로 코드에 그대로 있다 [확인] RerankerUtil.scala:16. 두 레포는 "코드 중복 0"이라고 알려져 있지만 점수 집계 수식만은 포팅됐고, 그 덕에 2025 값이 2026 상수의 하한 근거가 된다 [추정].

의미: 음수 총점은 0.001 스케일로 눌린다. 양수 후보의 최솟값이 0 + 0.001 = 0.001이므로 음수 후보는 항상 모든 양수 후보 아래로 간다. 순서는 보존하되 섞이지 않는 설계다.

같은 식이 죽은 파일 weighted_scorer.rs:87-89에도 남아 있다 — 거기서는 p::NEGATIVE_WEIGHTS_SUM / p::WEIGHTS_SUM을 상수로 읽는다. 즉 가중치 합을 요청별로 계산하는 구조로 바뀌기 전 세대의 형태다 [추정].

3. 헤드 목록은 세대마다 다르고, 레포 안에서도 여러 벌이 돌아다닌다

2026 Rust 랭커가 읽는 헤드 22개 [확인] ranking_scorer.rs:12-39,146-170:

구분 헤드
참여 favorite, reply, retweet, quote, share, share_via_dm, share_via_copy_link
클릭·체류 click, profile_click, photo_expand, quoted_click, dwell
영상 vqv, quoted_vqv
연속값 cont_dwell_time, cont_click_dwell_time
팔로우 follow_author
음수 not_interested, block_author, mute_author, report, not_dwelled

공개된 Phoenix 체크포인트의 ACTIONS19개다 — quoted_vqv, not_dwelled, click_dwell_time이 없다. [확인]phoenix/runners.py:233-253

공개 모델(19)과 프로덕션 서빙 코드(22)의 헤드 수가 안 맞는다. 공개 체크포인트가 구버전이거나 일부 헤드를 뺀 것이다 [추정]. vm_ranker.rs가 proto로 복사하는 필드는 21개(quoted_vqv_score 누락)라서, 세 군데가 전부 다르다. [확인]vm_ranker.rs:69-93

2025 Scala는 두 종류의 랭커가 각자 헤드 세트를 갖는다:

Navi는 음수 헤드가 negative_feedback_v2 하나뿐이다. 2026의 5개(not_interested/block/mute/report/not_dwelled)로 세분화된 건 세대 간 변화다. [확인]

함정: 2025 레포에는 PredictedScoreFeature라는 같은 이름의 파일이 두 개 있고 헤드 세트가 다르다 — model/(15개 Seq, 실사용)와 product/scored_tweets/scorer/(17개 Set, Report·StrongNegativeFeedback·VideoPlayback50 등 포함). 실제 스코어러가 import하는 건 model/ 쪽이다. scorer/ 쪽을 근거로 "X는 report 헤드를 쓴다"고 쓰면 틀린다. [확인]RerankerUtil.scala:4의 import 경로

4. 가중치 주입 경로가 요청 단위일 수 있다

2025 Navi는 가중치를 param이 아니라 query feature에서 읽는다.

weight = query.features.get(feature.weightQueryFeature).getOrElse(0.0)

[확인]RerankerUtil.scala:28-29. 헤드마다 weightQueryFeatureName / biasQueryFeatureName / debiasQueryFeatureName 3종이 있다 [확인] PredictedScoreFeature.scala:43-56.

즉 목적 함수가 요청마다 달라질 수 있다. 유저 세그먼트별·실험별로 다른 가중치를 꽂는 구조로 보이나, 값을 채우는 upstream 서비스는 레포에 없다 [추정].

같은 레포에서 Phoenix 쪽은 param에서 직접 읽는다(query.params(feature.modelWeightParam)) — 두 랭커의 주입 경로가 다르다. [확인]PhoenixModelRerankingScorer.scala:51

5. 부적격 헤드는 0이 아니라 bias로 대체된다

영상이 없는 포스트에 vqv 헤드를 쓸 수 없다. X 2025는 이때 0을 넣지 않고 bias 값을 넣는다.

score = if (isEligible) predictedScoreOpt.getOrElse(0.0) else bias

[확인]RerankerUtil.scala:34-37. 0을 넣으면 영상 없는 포스트가 구조적으로 불리해지기 때문으로 보인다 [추정].

자격 조건도 손으로 정한 것들이다: 영상 헤드는 duration >= 10초, EnableDwellOrVQVParam이 켜지면 Dwell과 VQV가 상호 배타가 된다. [확인]PredictedScoreFeature.scala:154-164,220-232,273-279

임계값 클리핑도 있다. VQV 점수가 ScoreThresholdForVQVParam 미만이면 0.0으로 잘리고, binary scheme이 켜지면 상수로 대체된다. [확인]:176-188

6. 가중합 이후에도 점수가 계속 바뀐다

가중합은 끝이 아니다. 2026 Rust의 순서:

  1. 헤드별 가중합 → offset_score (ranking_scorer.rs:146-183)
  2. normalize_score(candidate, raw)함수 본체가 공개 안 됨(crate::util 모듈 부재)
  3. 작성자 다양성 감쇠 — 같은 작성자의 n번째 포스트에 (1 - floor) · decay^n + floor 곱하기 (:186-217)
  4. OON 배수 — in-network가 아닌 후보에만 effective_oon 곱하기 (:220-239, 272-275)
  5. 이후 VMRanker(DPP 슬레이트 최적화)가 점수를 통째로 덮어쓸 수 있다 (vm_ranker.rs:47-52)

[확인]ranking_scorer.rs:248-284, phoenix_candidate_pipeline.rs:300

2025 Scala도 같은 성격이지만 덧셈이다. DiversityRescoringFeatureHydrator가 MMR로 score = relevance + diversityWeight · minDistance를 계산해 ScoreFeature덮어쓴다. [확인]DiversityRescoringFeatureHydrator.scala:70,102

→ 다양성 레이어 상세는 feed-diversity.md.

케이스: Netflix — 헤드×가중치 프레임 자체를 벗어난다

X·Meta의 Σ w·P(action) 프레임에 Netflix를 넣으면 안 맞는다. 공개된 목적함수가 가중합이 아니라 보상 함수다.

[확인] Recommending for Long-Term Member Satisfaction at Netflix, 2024:

다만 프레임이 다르다고 헤드가 없는 건 아니다. FM은 multi-token/다중 예측 헤드를 갖고 [확인] FM 블로그 2025, 가중 목적함수도 쓴다("5분 트레일러와 2시간 완주가 같을 수 없다"). 차이는 그 가중이 학습 목적함수 안에 있고, 서빙 시점의 Σ w·P 결합층이 공개 문헌에 없다는 것. GenRec(2026)은 아예 reward-weighted loss로 학습한 LLM이 직접 랭킹한다 [확인] GenRec 블로그 — 결합이 모델 파라미터로 흡수된 형태다.

덤: 화면의 % Match는 랭킹 점수가 아니라 표시용이다 — "시청 습관·행동 분석만으로" 산출 [확인] 2017 발표. 유저에게 보이는 숫자와 정렬에 쓰는 숫자가 다른 시스템의 명시적 사례. → netflix.md

이 케이스에서 일반화할 수 있는 것

누가 이걸 쓰는가

출처

공개 소스코드

Meta 엔지니어링 블로그 (결합 수식의 1차 출처)

Meta Transparency Center (규제 대응 1차 문서)

Netflix