详细介绍推特 Twitter 的演算法以及推送机制。

我将会详细解释我个人对 Twitter 演算法代码的理解并附上来源,然而不同的工程师分析出的结论可能会有所不同,另外 Twitter 演算法可能随时会发生变化。因此,请理性看待这篇文章的内容,仅供参考。

如需转载请注明出处,并附上本文的链接。

推文权重演算法

Twitter 的演算法模型中有一个叫做「Heavy Ranker」的机器学习模型,在首页的「为你推荐」中检索推文的权重分数进行排名。

下面是总结的各项类型的权重值以及上限数值。不同的功能都有不同的上限值,即使某种功能的数量很大,也不会无限叠加增益。

类型 分数 上限
点赞 +0.5 分 100 分
用户点赞了推文。
转发 +1.0 分 100 分
用户转发了推文。
回复 +13.5 分 100 分
用户对推文回复。
进入个人主页 +12 分 1000000 分
从推文进入作者主页并点赞或者回复。
视频被播放 +0.005 分 100 分
视频类型的推文,用户观看进度至少 50% 以上。
作者回复 +75 分 200 分
推文的作者回复了用户对该推文的回复。
回复的回复 +11 分 1000000 分
用户对推文的回复进行点赞或者回复。
浏览 2 分钟 +10 分 1000000 分
用户浏览推文停留不少于 2 分钟。
负面反应 -74 分 -1000 分
用户对推文做出负面反应(对这条推文不感兴趣、屏蔽作者、隐藏作者)。
被举报 -369 分 -20000 分
用户举报了推文。

各项类型分数相关代码。由于上限这部分代码太长,所以就不放过来了,可以从下面的来源中找到。

scored_tweets_model_weight_fav: 0.5
scored_tweets_model_weight_retweet: 1.0
scored_tweets_model_weight_reply: 13.5
scored_tweets_model_weight_good_profile_click: 12.0
scored_tweets_model_weight_video_playback50: 0.005
scored_tweets_model_weight_reply_engaged_by_author: 75.0
scored_tweets_model_weight_good_click: 11.0
scored_tweets_model_weight_good_click_v2: 10.0
scored_tweets_model_weight_negative_feedback_v2: -74.0
scored_tweets_model_weight_report: -369.0

总分数的算法

score = sum_i { (weight of engagement i) * (probability of engagement i) }

总分 = 权重 * 参与数

举个例子,例如有 10 个用户给推文点赞了,那么点赞的权重是 +0.5 分,参与数有 10 个用户,也就是 0.5 * 10 = 5。总计是 5 分的权重。

推文演算法和总分数算法来源:README.md

上限来源:ScoredTweetsParam.scala

不过网上还有一个广为流传的说法,点赞权重 30,转发权重 20。我查了一下这个说法的代码来源。

private def getLinearRankingParams: ThriftRankingParams = {
  ThriftRankingParams(
    `type` = Some(ThriftScoringFunctionType.Linear),
    minScore = -1.0e100,
    retweetCountParams = Some(ThriftLinearFeatureRankingParams(weight = 20.0)),
    replyCountParams = Some(ThriftLinearFeatureRankingParams(weight = 1.0)),
    reputationParams = Some(ThriftLinearFeatureRankingParams(weight = 0.2)),
    luceneScoreParams = Some(ThriftLinearFeatureRankingParams(weight = 2.0)),
    textScoreParams = Some(ThriftLinearFeatureRankingParams(weight = 0.18)),
    urlParams = Some(ThriftLinearFeatureRankingParams(weight = 2.0)),
    isReplyParams = Some(ThriftLinearFeatureRankingParams(weight = 1.0)),
    favCountParams = Some(ThriftLinearFeatureRankingParams(weight = 30.0)),
    langEnglishUIBoost = 0.5,
    langEnglishTweetBoost = 0.2,
    langDefaultBoost = 0.02,
    unknownLanguageBoost = 0.05,
    offensiveBoost = 0.1,
    inTrustedCircleBoost = 3.0,
    multipleHashtagsOrTrendsBoost = 0.6,
    inDirectFollowBoost = 4.0,
    tweetHasTrendBoost = 1.1,
    selfTweetBoost = 2.0,
    tweetHasImageUrlBoost = 2.0,
    tweetHasVideoUrlBoost = 2.0,
    useUserLanguageInfo = true,
    ageDecayParams = Some(ThriftAgeDecayRankingParams(slope = 0.005, base = 1.0))
  )
}

这段代码确实写着点赞的权限默认 30,转发 20,不过这段代码已经被移除,原因是这段代码是多余的,并没有使用这段代码进行排名。

来源: EarlybirdTensorflowBasedSimilarityEngine.scala

广告权重演算法

广告:+10000 分权重

object AdsCandidateGenerationScoreBoostFactor
    extends FSBoundedParam[Double](
      name = "ads_candidate_generation_score_boost_factor",
      default = 10000.0,
      min = 1.0,
      max = 100000.0
    )

来源:AdsParams.scala

Twitter Blue 权重演算法

开通了 Twitter Blue 可以获得额外的权重加成。

类型 分数
Twitter Blue (关注) 4x
已经关注的账户。
Twitter Blue (非关注) 2x
未关注的账户。

Twitter Blue 权重相关的代码

object BlueVerifiedAuthorInNetworkMultiplierParam
    extends FSBoundedParam[Double](
      name = "home_mixer_blue_verified_author_in_network_multiplier",
      default = 4.0,
      min = 0.0,
      max = 100.0
    )

object BlueVerifiedAuthorOutOfNetworkMultiplierParam
    extends FSBoundedParam[Double](
      name = "home_mixer_blue_verified_author_out_of_network_multiplier",
      default = 2.0,
      min = 0.0,
      max = 100.0
    )

Twitter Blue 权重来源:HomeGlobalParams.scala

账号权重演算法

根据账号的状态计算账号的权重值。
被停用的账号:0 分
已验证的账号:100 分
未验证的账号:根据设备、账号情况和年龄综合计算分数,最高 55 分。
在此基础上还会计算关注数和粉丝数的比例来决定最终的权重值。

相关代码

object UserMass {
  private val currentTimestamp = Time.now.inMilliseconds
  private val constantDivisionFactorGt_threshFriendsToFollowersRatioUMass = 5.0
  private val threshAbsNumFriendsUMass = 500
  private val threshFriendsToFollowersRatioUMass = 0.6
  private val deviceWeightAdditive = 0.5
  private val ageWeightAdditive = 0.2
  private val restrictedWeightMultiplicative = 0.1
  def getUserMass(combinedUser: CombinedUser): Option[UserMassInfo] = {
    val user = Option(combinedUser.user)
    val userId = user.map(_.id).getOrElse(0L)
    val userExtended = Option(combinedUser.user_extended)
    val age = user.map(_.created_at_msec).map(DateUtil.diffDays(_, currentTimestamp)).getOrElse(0)
    val isRestricted = user.map(_.safety).exists(_.restricted)
    val isSuspended = user.map(_.safety).exists(_.suspended)
    val isVerified = user.map(_.safety).exists(_.verified)
    val hasValidDevice = user.flatMap(u => Option(u.devices)).exists(_.isSetMessaging_devices)
    val numFollowers = userExtended.flatMap(u => Option(u.followers)).map(_.toInt).getOrElse(0)
    val numFollowings = userExtended.flatMap(u => Option(u.followings)).map(_.toInt).getOrElse(0)
    if (userId == 0L || user.map(_.safety).exists(_.deactivated)) {
      None
    } else {
      val mass =
        if (isSuspended)
          0
        else if (isVerified)
          100
        else {
          var score = deviceWeightAdditive * 0.1 +
            (if (hasValidDevice) deviceWeightAdditive else 0)
          val normalizedAge = if (age > 30) 1.0 else (1.0 min scala.math.log(1.0 + age / 15.0))
          score *= normalizedAge
          if (score < 0.01) score = 0.01
          if (isRestricted) score *= restrictedWeightMultiplicative
          score = (score min 1.0) max 0
          score *= 100
          score
        }
      val friendsToFollowersRatio = (1.0 + numFollowings) / (1.0 + numFollowers)
      val adjustedMass =
        if (numFollowings > threshAbsNumFriendsUMass &&
          friendsToFollowersRatio > threshFriendsToFollowersRatioUMass) {
          mass / scala.math.exp(
            constantDivisionFactorGt_threshFriendsToFollowersRatioUMass *
              (friendsToFollowersRatio - threshFriendsToFollowersRatioUMass)
          )
        } else {
          mass
        }
      Some(UserMassInfo(userId, adjustedMass))
    }
  }
}

这里定义了一些变量值:
currentTimestamp: 当前的时间戳(用来计算账号和当前时间的差距)。

constantDivisionFactorGt_threshFriendsToFollowersRatioUMass: 关注数与粉丝数常量因子(用于衡量用户好友与粉丝比例)。

threshAbsNumFriendsUMass: 用户最小关注数(用于计算用户权重)

threshFriendsToFollowersRatioUMass: 关注数与粉丝数阈值(用户关注数与粉丝数比例的最小允许值,当比例超过这个值时,会造成负面影响)。

deviceWeightAdditive: 设备权重

ageWeightAdditive: 年龄权重(似乎没有用到)

restrictedWeightMultiplicative: 受限用户权重

计算分数的时候会先判断账号的状态,如果账号被停用了,分数为 0;如果是验证过的账号分数为 100,如果是没有验证的账号会根据条件进行计算。

if (isSuspended)
  0
else if (isVerified)
  100
else {
  var score = deviceWeightAdditive * 0.1 +
    (if (hasValidDevice) deviceWeightAdditive else 0)
  val normalizedAge = if (age > 30) 1.0 else (1.0 min scala.math.log(1.0 + age / 15.0))
  score *= normalizedAge
  if (score < 0.01) score = 0.01
  if (isRestricted) score *= restrictedWeightMultiplicative
  score = (score min 1.0) max 0
  score *= 100
  score
}

这段代码中会先判断是否持有有效设备再加上初始权重,不过我没找到其它更细节的代码检测设备的有效性。如果设备有效返回 0.5 分,如果无效返回 0 分。deviceWeightAdditive 的默认值是 0.5,那么根据下面的算法,假设设备是有效的情况下 0.5 * 0.1 + 0.5 = 0.55 分。

deviceWeightAdditive * 0.1 +
  (if (hasValidDevice) deviceWeightAdditive else 0)

再根据年龄进行计算,如果年龄大于 30 岁,返回 1 分,如果年龄小于或等于 30 岁,就会进行下一步的计算,将年龄除以 15,再加上 1,然后计算自然对数。

自然对数是以常数 e 为底数的对数,公式:ln(x)。

假设年龄是 22 岁,ln(1 + 22 / 15) ≈ 0.9028677115420144

如果自然对数小于 1 返回自然对数,如果自然对数大于 1,则返回 1,也就是说 normalizedAge 的值最大不会超过 1。

最后再将计算的结果乘以之前算好的分数,即 0.55 * 0.9028677115420144 = 0.49657724134810793

val normalizedAge = if (age > 30) 1.0 else (1.0 min scala.math.log(1.0 + age / 15.0))
  score *= normalizedAge

下面又进行了一些判断,如果分数小于 0.01 的情况下,返回 0.01,不过根据上面的条件,分数并不会低于 0.01 分。

如果账号受限了,会将总分数乘以受限权重,也就是 0.1。

如果分数大于 1,会将分数设为 1,如果分数小于 1,则会返回当前分数;最后将分数乘以 100。

假设账号没有受限的情况下,根据上面的条件,得出分数为 49.657724134810793。

if (score < 0.01) score = 0.01
if (isRestricted) score *= restrictedWeightMultiplicative
score = (score min 1.0) max 0
score *= 100

接下来会根据账号的关注数和粉丝数进行计算分数。1 加上关注数的和除以1 加上粉丝数的和。假设账号关注数有 600,粉丝数有 450,那么关注数和粉丝数的比例是:(1 + 600) / (1 + 450) ≈ 1.3325942350332594。

val friendsToFollowersRatio = (1.0 + numFollowings) / (1.0 + numFollowers) 

先判断两个条件,一个是关注数是否大于设定的用户最小关注数 (500) 和比例是否大于关注数与粉丝数阈值 (0.6),如果不满足这两个条件则不会进行下面的计算,直接返回之前计算好的分数。

如果满足以上的两个条件,会先用比例减去关注数与粉丝数阈值的差乘以关注数与粉丝数常量因子的值,结果为:5 * (1.3325942350332594 - 0.6) = 3.6629711751662968,再根据这个结果使用指数函数计算幂值。

指数函数公式:f(x) = a^x

那么根据计算的结果计算指数函数的结果 e^3.6629711751662968 ≈ 2.565617039296528,这个结果就是最终的账号权重分数。

val adjustedMass =
  if (numFollowings > threshAbsNumFriendsUMass &&
    friendsToFollowersRatio > threshFriendsToFollowersRatioUMass) {
    mass / scala.math.exp(
      constantDivisionFactorGt_threshFriendsToFollowersRatioUMass *
        (friendsToFollowersRatio - threshFriendsToFollowersRatioUMass)
    )
  } else {
    mass
  }

来源:UserMass.scala

账号权重计算器
账号状态
是否受限
生日
关注数
粉丝数

排名权重演算法

类型 分数
UI 英文,推文非英文 0.3x
Twitter 的界面语言是英文,但是发布的推文不是英文。
推文英文,UI 非英文 0.7x
发布的推文是英文,但是 Twitter 的界面语言不是英文。
UI 和语言不同 0.1x
Twitter 的界面语言是英文,但是发布的推文不是英文。
无法识别语言 0.01x
推文的语言不是用户可理解的语言,也不会界面语言。
UI 英文,推文非英文 0.3x
Twitter 的界面语言是英文,但是发布的推文不是英文。
图片、视频、新闻链接 1.0x
根据媒体的数量累计加成。
外部链接 隐藏
如果链接非媒体链接,而且没有足够的互动权重,会被隐藏。

降级属性
无文字内容 1.0x
推文的内容没有任何文字。
只有链接 1.0x
推文只有链接,没有其它内容。
只有名字 1.0x
推文只有名字,没有其它内容。

由于这段代码太长,我只把部分内容摘取出来,下面的代码不是完整的代码。 这里定义了各项类型的初始值。

maxHitsPerUser 默认值是 3,代表演算法最多推送 3 条推文,如果账号权重大于 maxTweepcredForAntiGaming 设置的值,演算法会推送所有的推文。

struct ThriftRankingParams {
  30: optional double langEnglishUIBoost = 0.3
  31: optional double langEnglishTweetBoost = 0.7
  32: optional double langDefaultBoost = 0.1
  43: optional double unknownLanguageBoost = 0.01
  60: optional bool enableHitDemotion = 0
  61: optional double noTextHitDemotion = 1.0
  62: optional double urlOnlyHitDemotion = 1.0
  63: optional double nameOnlyHitDemotion = 1.0
  64: optional double separateTextAndNameHitDemotion = 1.0
  65: optional double separateTextAndUrlHitDemotion = 1.0
  102: optional double multipleHashtagsOrTrendsBoost = 1
  108: optional double tweetHasImageUrlBoost = 1
  109: optional double tweetHasVideoUrlBoost = 1
  110: optional double tweetHasNewsUrlBoost = 1
}(persisted='true')


struct ThriftFacetRankingOptions {
  35: optional i32 maxHitsPerUser = 3
  36: optional i32 maxTweepcredForAntiGaming = 65
}(persisted='true')

如果推文包含图片、视频或者新闻链接,会在原来的数量上 +1。

if (data.hasImageUrl || data.hasVideoUrl) {
  relevanceStats.setNumWithMedia(relevanceStats.getNumWithMedia() + 1);
}
if (data.hasNewsUrl) {
  relevanceStats.setNumWithNews(relevanceStats.getNumWithNews() + 1);
}

如果内容包含图片、视频或者新闻链接,会给推文增加权重,计算方式:总分数 * (1 * (图片数量 + 1))

// Media/News url boosts.
if (data.hasImageUrl || data.hasVideoUrl) {
  data.hasMedialUrlBoostApplied = true;
  boostedScore *= params.tweetHasMediaUrlBoost;
}
if (data.hasNewsUrl) {
  data.hasNewsUrlBoostApplied = true;
  boostedScore *= params.tweetHasNewsUrlBoost;
}

下面的这段代码也说明了图片、视频或者新闻链接会有额外的权重加成,无文字、只有链接、只有名字会降低权重。

if (scoringData.tweetHasTrendsBoostApplied) {
  boostDetails.add(Explanation.match(
    (float) params.tweetHasTrendBoost, "[x] Tweet has trend boost"));
}
if (scoringData.hasMedialUrlBoostApplied) {
  boostDetails.add(Explanation.match(
    (float) params.tweetHasMediaUrlBoost, "[x] Media url boost"));
}
if (scoringData.hasNewsUrlBoostApplied) {
  boostDetails.add(Explanation.match(
    (float) params.tweetHasNewsUrlBoost, "[x] News url boost"));
}
boostDetails.add(Explanation.match(0.0f, "[FIELDS HIT] " + scoringData.hitFields));
if (scoringData.hasNoTextHitDemotionApplied) {
  boostDetails.add(Explanation.match(
    (float) params.noTextHitDemotion, "[x] No text hit demotion"));
}
if (scoringData.hasUrlOnlyHitDemotionApplied) {
  boostDetails.add(Explanation.match(
    (float) params.urlOnlyHitDemotion, "[x] URL only hit demotion"));
}
if (scoringData.hasNameOnlyHitDemotionApplied) {
  boostDetails.add(Explanation.match(
    (float) params.nameOnlyHitDemotion, "[x] Name only hit demotion"));
}
if (scoringData.hasSeparateTextAndNameHitDemotionApplied) {
  boostDetails.add(Explanation.match((float) params.separateTextAndNameHitDemotion,
    "[x] Separate text/name demotion"));
}
if (scoringData.hasSeparateTextAndUrlHitDemotionApplied) {
  boostDetails.add(Explanation.match((float) params.separateTextAndUrlHitDemotion,
    "[x] Separate text/url demotion"));
}

来源:ranking.thrift
来源:FeatureBasedScoringFunction.java

推文发布时间演算法

推文会随着时间的推移而降低分数。

相关代码

struct ThriftAgeDecayRankingParams {
  // the rate in which the score of older tweets decreases
  1: optional double slope = 0.003
  // the age, in minutes, where the age score of a tweet is half of the latest tweet
  2: optional double halflife = 360.0
  // the minimal age decay score a tweet will have
  3: optional double base = 0.6
}(persisted='true')

这里使用了时间衰减函数,衰减率为 0.003,衰减半衰期为 360 分钟。这个衰减函数表示,随着时间的推移,一个推文的分数将逐渐下降,下降的速率取决于时间的差距和衰减率。在这种情况下,当一个推文的发布时间与当前时间相差 500 分钟时,可以通过以下面的公式计算推文的衰减分数:

衰减分数 = 基础分数 * exp(-衰减率 * (发布时间 - 当前时间) / 衰减半衰期)

来源:ranking.thrift

推文权重衰减计算器
发布时间

违反政策的内容

如果推文的内容包含违反政策的内容会被降低权重,包括但不限于:鼓励自残、仇恨行为、无端血腥、宣扬暴力、鼓励围攻骚扰、死亡前或死者用户、发布私人信息、侵犯隐私权、威胁曝光、暴力性行为、性骚扰、暴力威胁、干预选举、虚假选举信息、黑客内容、欺诈、平台操作、虚假乌克兰信息、误导信息、虚假医疗信息等。

相关代码

val reasonToPolicyInViolation: Map[Reason, PolicyInViolation] = Map(
  AbuseEpisodic -> PolicyInViolation.AbusePolicyEpisodic,
  AbuseEpisodicEncourageSelfHarm -> PolicyInViolation.AbusePolicyEpisodicEncourageSelfharm,
  AbuseEpisodicHatefulConduct -> PolicyInViolation.AbusePolicyEpisodicHatefulConduct,
  AbuseGratuitousGore -> PolicyInViolation.AbusePolicyGratuitousGore,
  AbuseGlorificationOfViolence -> PolicyInViolation.AbusePolicyGlorificationofViolence,
  AbuseMobHarassment -> PolicyInViolation.AbusePolicyEncourageMobHarassment,
  AbuseMomentOfDeathOrDeceasedUser -> PolicyInViolation.AbusePolicyMomentofDeathDeceasedUser,
  AbusePrivateInformation -> PolicyInViolation.AbusePolicyPrivateInformation,
  AbuseRightToPrivacy -> PolicyInViolation.AbusePolicyRighttoPrivacy,
  AbuseThreatToExpose -> PolicyInViolation.AbusePolicyThreattoExpose,
  AbuseViolentSexualConduct -> PolicyInViolation.AbusePolicyViolentSexualConduct,
  AbuseViolentThreatHatefulConduct -> PolicyInViolation.AbusePolicyViolentThreatsHatefulConduct,
  AbuseViolentThreatOrBounty -> PolicyInViolation.AbusePolicyViolentThreatorBounty,
  OneOff -> PolicyInViolation.OneOff,
  VotingMisinformation -> PolicyInViolation.MisinformationVoting,
  HackedMaterials -> PolicyInViolation.HackedMaterials,
  Scams -> PolicyInViolation.Scam,
  PlatformManipulation -> PolicyInViolation.PlatformManipulation,
  MisinfoCivic -> PolicyInViolation.MisinformationCivic,
  MisinfoCrisis -> PolicyInViolation.AbusePolicyUkraineCrisisMisinformation,
  MisinfoGeneric -> PolicyInViolation.MisinformationGeneric,
  MisinfoMedical -> PolicyInViolation.MisinformationMedical,
)

来源:PublicInterestRules.scala

推文推送机制

下面以“我”为例子,当我发布了一个推文,或者对某一个帖子进行互动(点赞、回复或分享)的操作,那么就会将这篇推文推送到我的粉丝。

如果我的粉丝对推文进行互动,那么就会将这篇推文推送到他们的粉丝,如果不互动就不会继续再往外推送。

粉色是蓝色粉丝的关系,也就是我粉丝的粉丝。

如果我的粉丝的粉丝,也就是图中粉色的粉丝,对这篇推文互动,那么就会推送到他们账号上的粉丝。
绿色是粉色粉丝的关系。

如果他们再进行互动,就会继续往外推送,以此类推。