Alexander D Huang
Alexander D Huang

Categories

Logs

Tags

想必有人会对豆瓣图书搜索结果的排序感到困惑吧?举个例子,假设我们搜索“JavaScript”,我们会发现排在第 3 位的是《JavaScript DOM编程艺术 (第2版)》,豆瓣评分为 8.7,有 1505 人评价;但是排在第 4 位的《JavaScript语言精粹》的豆瓣评分更高,为 9.1,而且评价人数更多,有 1792 人。更糟糕的情况是,我们想要找的高分图书往往出现在好几页之后。因此,我开发了一个 Web 应用,它基于贝叶斯平均对搜索结果进行排序。

为什么用贝叶斯平均?

如果我只想对搜索到的图书按评分排序,为什么不直接用豆瓣的评分,而用贝叶斯平均分?

原因之一是,我实在不知道豆瓣评分是怎么计算的!在此,我只能假设它就是一个简单的算术平均。现在我们假设某本书 A 有一个人评价,评分为 9.5;而某本书 B 有 100 个人评价,算术平均分为 8.9。哪一本书应当排在前面?哪一本书更值一读?算术平均不能回答这些问题。

什么是贝叶斯平均分?它的公式为

其中 $x_i$ 为某一投票项的某人给出的评分,$n$ 为某一投票项的投票人数;$C$ 是一个与数据集大小成正比的数,我们可以令它等于每一个投票项的平均投票人数;$m$ 为每一个投票项的预设评分,我们可以令它等于总体投票人给出的评分的算术平均值。因此,贝叶斯平均 $\bar{x}$ 是一个随着投票人数的增加而不断修正的值。

它的意义也很容易看出,即相当于我们预先给每个投票项投了 $C$ 张票,每张票的评分为 $m$,然后再加上新增的用户投票计算一个算术平均分。这是贝叶斯推断的一个与直觉相悖的特点,即用后验统计作为先验条件。

通过豆瓣 Open API 获取数据

首先看如何获取豆瓣图书的数据。搜索图书的 API 为

https://api.douban.com/v2/book/search?q=[keywords]

其中参数 q 为搜索的关键词。

它以 JSON 格式返回数据,类似于

{
    "count": 20,
    "start": 0,
    "total": 199,
    "books": [
        { ... },
        { ... },
        ...
    ]
}

其中 count 为本页所含的数据条目数,start 为本页所含的数据条目的起始索引,total 为总的数据条目数,books 为本页数据条目列表。

搜索 API 可以带上参数 &start=[start],则其返回的数据条目从索引 [start] 开始。

JavaScript 实现

现在我们可以编写 JavaScript 程序去计算豆瓣图书的贝叶斯平均分了。

由于跨域访问的限制,在 JavaScript 中,我们不能直接通过 HTTPRequest 访问豆瓣 API,但是我们可以用 JSONP 技术。

/**
 * @param url 访问的 URL
 * @param callback 回调函数的名字,在此函数中我们处理 url 返回的 JSON 数据
 */ 
function jsonp(url, callback)
{
    const script = document.createElement("script");
    script.type = "text/javascript";
    script.src = url + `&callback=${callback}`;
    script.async = true;
    document.body.append(script);
}

我们的回调函数命名为 jsonpCallback,在此函数中,我们解析、缓存数据,并最终计算出每一本书的贝叶斯平均分。

// 收集到的图书数据列表
let books = [];

// 读取的最大图书数据条目数量
const MAX_BOOKS = 2048;

// 已经读取的图书数据条目数量
let booksRead = 0;

// 搜索 URL
let searchURL;

function jsonpCallback(page)
{
    const count = page['count'];
    const start = page['start'];
    const total = page['total'] > MAX_BOOKS ? MAX_BOOKS : page['total'];

    if (total == 0) return;

    if (start == 0) {
        booksRead = 0;
        // 遍历每一页数据
        for (let s = start + count; s < total; s += count) {
            jsonp(searchURL + `&start=${s}`, "jsonpCallback");
        }
    }

    page['books'].forEach(book => {
        // 只读取评分大于 0 的图书
        if (parseFloat(book['rating']['average']) > 0) {
            books.push(book);
        }
    });

    booksRead += count;

    // 已读取的条目数达到了总数,解析数据完毕,可以计算贝叶斯平均了
    if (booksRead >= total) {
        // 计算每一本书的贝叶斯平均分
        calculateBayesian(books);
        // 依贝叶斯平均分从大到小排序
        books.sort((a, b) => {
            return b['rating']['bayesian'] - a['rating']['bayesian'];
        });
        // 展示结果给用户查看
        showBooks();
    }
}

计算贝叶斯平均的代码也就是上面所提的公式的翻译了:

function calculateBayesian(books) {
    let allRaters = 0;
    let allScore = 0;

    for (let book of books) {
        const n = book['rating']['numRaters'];
        allRaters += n;
        allScore += n * parseFloat(book['rating']['average']);
    }

    const C = allRaters / books.length;
    const m = allScore / allRaters;

    for (let book of books) {
        const n = book['rating']['numRaters'];
        book['rating']['bayesian'] = (C * m + n * parseFloat(book['rating']['average'])) / (C + n);
    }
}

最后再封装一下:

function sortBooks(keywords)
{
    books = [];

    searchURL = `${api}?q=${encodeURI(keywords)}`;
    jsonp(searchURL, "jsonpCallback");
}

用户输入 keywords,点击“搜索”按钮,即调用 sortBooks(keywords)