本次博客开始评分板块的功能实现。

由于前一段时间多在了解claude code相关内容,于是博客进度略有迟缓。

一、从 TypeScript 的 5 项 100 分到 Python 的 6 维加权

在任务 2.4 之前,项目里已经有一个评分逻辑,写在 TypeScript 端的 `recommendationService.ts` 里。它的做法很简单:5 个维度,每个维度赋予一个固定分值(预算匹配 30、户型匹配 25、位置匹配 20、通勤 15、标签匹配 10),命中就加分,不命中就跳过,最后按总分排序。

这个方法有两个局限:

1. 权重不可调。一个对价格极度敏感的用户和对通勤极度敏感的用户,得到的是同一套评分逻辑;

2. 评分是二元的。预算匹配要么 30 分要么 0 分,不存在"接近预算"这种中间状态。一个预算 4500~6500 的用户,看到 6400 和 4600 的房子评分一样,但实际上前者更贵更接近上限。

新的评分引擎需要解决这两个问题:权重动态可调、评分连续化。

二、六个维度的选择逻辑

我们的目标要求是 ≥6 个维度。选择哪些维度,取决于两点:数据可得性(爬虫和地图服务能提供什么)和区分度(不同房源在这个维度上能否拉开差距)。

最终确定的六个维度:

1. 价格(price) :爬虫 :最直接影响决策的因素,必须连续化处理,不能只判断"在不在预算内" 

2.通勤(commute) :地图服务(步行到最近地铁的时间): 数据可得且区分度很高——同城不同位置的步行时间可以从 2 分钟到 25 分钟 

3.配套(amenities):地图服务(周边 POI 数量): 反映生活便利程度,但容易"刷分"——如果一个维度就能决定总分,评分就失去了参考价值 

4.户型(room_type): 爬虫: 租住类型匹配 + 房间数匹配,属于用户基本需求 

5.朝向楼层(orientation_floor) :爬虫:朝向直接影响居住体验(南北通透 > 纯南 > 东 > 西),面积反映空间合理性 

6.装修品质(decoration) :爬虫(特征标签): 通过用户偏好标签与房源特征的相似度来衡量,体现个性化 

三、评分函数的设计取舍

1.价格:从二元到高斯衰减

原来的逻辑是"在预算内 = 30 分"——这是一种二元判断。但一个预算是 4500~6500 的用户,看到 6400 和 4600 的房源,虽然都在预算内,但心理感受完全不同。

新的价格评分用了**高斯衰减**:以预算区间中点为满分点(偏离度为 0),价格越偏离中点,分数平滑下降。偏离度用"半区间宽度"做归一化,使得区间的实际物理意义被保留。

$$score = 100 \times e^{-deviation^2}$$

其中 `deviation = |price - ideal| / half_range`。

如果价格超出了预算区间,则退化为**线性惩罚**:低于预算下限给 75 分起(省钱不算坏事),超出预算上限从 80 分快速衰减到 0。

这样处理后,同一个预算区间内的两个房源可以有不同的价格分,而不是一视同仁。

2.通勤:分档而非连续

通勤的评分用了**阶梯函数**而不是连续函数。原因是步行时间本身就有认知上的"台阶"——5 分钟和 6 分钟对用户来说差别不大,但 5 分钟和 15 分钟是完全不同的体验。

分档标准:≤5min=100 分,6-10min=85 分,11-15min=65 分,16-20min=40 分,>20min=15 分。这个分档参考了实际租房场景中对"走得近"的认知——10 分钟以内算"地铁房",15 分钟是多数人能接受的上限。

3.配套:设上限防止刷分

配套评分的核心问题是如何防止单一类别主导总分。如果一个地方有 8 个地铁站但零超市零医院,它的生活便利度实际上是不均衡的。

解决方案是每个 POI 类别设贡献上限:地铁最多贡献 40 分(2 个站就满了),超市最多 30 分(约 4 个超市),医院最多 30 分(约 5 个医院)。这样单维度最高 40+30+30=100,任何单一类别都不能"刷"满总分。

4.装修:Jaccard 相似度

用户偏好标签(如"近地铁"、"精装"、"有电梯")和房源特征标签之间用 Jaccard 相似度来衡量匹配程度。之所以选 Jaccard 而不是简单的命中计数,是因为它同时考虑了匹配项和未匹配项:

$$J(A,B) = \frac{|A \cap B|}{|A \cup B|}$$

用户说了 3 个偏好标签、命中了 1 个,和用户说了 1 个、命中了 1 个——前者 Jaccard=0.33,后者 Jaccard=1.0。前者代表需求更复杂但匹配度更低,这个区分是合理的。

四、动态权重的实现

用户可以通过 API 传入自定义权重,传几个都行——只传 `{"commute": 0.4}` 就会把通勤权重调高到 0.4,其余维度按默认权重比例重新分配。

引擎内部的处理逻辑是:**用户权重覆盖默认值 → 归一化**。归一化这步很关键——它确保了无论用户怎么调,所有权重加起来永远是 1.0,总分不会溢出也不会缩水。

merged = {**DEFAULT_WEIGHTS}  # 默认权重
merged.update(user_weights)    # 用户覆盖
total = sum(merged.values())   # 归一化
return {k: v/total for k, v in merged.items()}

这个设计让评分模型既能开箱即用,又能深度定制。

五、推荐理由的生成

评分不是最终目的——用户需要知道为什么这套房得分高。所以每个维度在计算分数的同时,也生成一条人类可读的理由:

价格: ¥2500 在预算内,偏离中点 11.1%

通勤: 到 千佛山站 步行 5 分钟 (极佳)

配套: 周边: 2个地铁站, 5个超市, 2个医院

户型: 类型匹配(整租); 房间数匹配(2室)

朝向: 朝南; 80㎡

装修: 匹配标签: 近地铁, 精装 (Jaccard=0.67)

得分 ≥65 的维度理由会被汇总到 `reasons` 列表中,作为推荐说明返回给用户。这样用户看到的不仅是"总分 93.7",还有"为什么是 93.7"。

六、和 TypeScript 端的关系

现有的 TypeScript 评分逻辑(`recommendationService.ts`)暂不删除——它仍然服务于 CLI 端的 demo 流程。Python 端的新评分引擎通过 API 暴露,后续当 Python API 承担更多职责时,两边的评分逻辑可以统一到 Python 端,CLI 端改为调用 API 获取结果。

更多推荐