1 面试背景

  • 面试公司:百度
  • 面试岗位:AIGC—搜推方向
  • 面试类型:技术一面
  • 面试时间:2024-12-06 14:00~15:00
  • 面试结果:通过 😊

2 整体感受

百度是纯简历面,简历从上到下挨个问的,除了有一个可能和深度学习关系不大没问,其余都问了。然后一道手撕代码题,由于自己之前做过,所以一看到题目就知道怎么做,然后很快就把代码写出来了。

之后面试官让我写一个样例跑一下,一跑一个准,直接秒了。由于我使用的语言是 C++,但是推荐算法一般都是 Python,所以面试管又让我用 Python 对数组进行排序,使用什么排序方法都可以。其实我早就开始用 Python 刷题了,所以很快就写出来了。

整体来说感觉很好,没有什么很致命的失误,唯一需要改进的可能是有时候表达再清晰一些。


3 提问的问题

3.1 特征工程

面试官:这个天池新闻推荐系统比赛中,你基本上用了哪些特征呢?

特征工程的话主要分为三个部分:用户特征、物品特征和用户历史交互特征。

3.1.1 用户特征

  1. 登录环境:用户点击环境、登录设备等特征(数据集中已给出)

  2. 用户的主题爱好特征:对于用户点击的历史文章主题进行一个统计,然后对于当前文章看看是否属于用户已经点击过的主题

  3. 用户新闻阅读数量

  4. 用户点击新闻的创建时间差的平均值

  5. 用户点击新闻的点击时间差的平均值

  6. 用户点击新闻的点击-创建时间差的统计值:mean,std

  7. 用户点击新闻的 click_datetime_hour 统计值

  8. 用户的字数爱好特征:对于用户点击的历史文章的字数统计,求一个均值

  9. 用户的时间统计特征:根据其点击的历史文章列表的点击时间和文章的创建时间做统计特征,比如求均值,这个可以反映用户对于文章时效的偏好

  10. 用户活跃度:基于用户的点击文章次数和点击时间构造可以表现用户活跃度的特征,具体如下:

    首先根据用户 user_id 分组,对于每个用户,计算点击文章的次数,两两点击文章时间间隔的均值。把点击次数取倒数和时间间隔的均值统一归一化,然后两者相加合并,该值越小,说明用户越活跃。

3.1.2 物品特征

  1. 新闻字数(数据集已给出)
  2. 新闻创建时间(数据集已给出)
  3. 热门物品:基于文章被点击次数和时间构造可以反映文章热度的特征

3.1.3 用户历史行为特征

对于每个用户召回的每个商品做特征,获取最后点击的 $n$ 个商品的 item_id

对于该用户的每个召回商品,计算与上面最后 $N$ 次点击商品的相似度、时间差特征、相似性特征、字数差特征、与该用户的相似性特征。

往往用户的最后一次点击会和其最后几次点击有很大的关联。所以我们就可以对于每个候选文章,做出与最后几次点击相关的特征如下:

  1. 候选物品与最后几次点击的相似性特征(embedding 内积)——这个直接关联用户历史行为
  2. 候选物品与最后几次点击的相似性特征的统计特征——统计特征可以减少一些波动和异常
  3. 候选物品与最后几次点击文章的字数差的特征——可以通过字数看用户偏好
  4. 候选物品与最后几次点击的文章建立的时间差特征——时间差特征可以看出该用户对于文章的实时性的偏好

3.2 特征使用

面试官:那这些不同维度的特征是怎么结合使用的呢?

上面构造的这些特征可以分为两类:离散型特征和连续型特征。

3.2.1 离散特征构建

离散特征是非常常见的一类特征,推荐系统中的用户属性数据、物品属性数据中就包含大量的类别特征,如性别、学历、视频的类型、标签、导演、国别等等。对于离散特征,一般可以采用如下 $2$ 种方式对特征进行编码(即特征构建)。

3.2.1.1 one-hot 编码

one-hot 编码通常用于离散特征(也叫类别特征),如果某个类别特征有 $k$ 类,我们将这 $k$ 类固定一个序关系,可以将每个值映射为一个 $k$ 维向量,其中这个值所在的分量为 $1$,其他分量为 $0$。比如性别进行编码的话,男可以编码为 $(1, 0)$,女可以编码为 $(0, 1)$。该方法当类别的数量很多时,特征空间会变得非常大。

当某个特征有多个类别时,这时 one-hot 编码可以拓展为 multi-hot 编码。下面举个视频推荐系统中 multi-hot 编码的例子。如果要将视频的标签进行 multi-hot 编码,怎么做呢?我们知道每个视频可能有多个标签(比如恐怖、科幻等),编码的时候将该视频包含的所有标签对应的分量处设置为 $1$,其他为 $0$。这里的 $n$ 是所有视频所有标签的总量,也即是全部可能的标签数量,一般是一个很大的数字(可能几万到几十万不等)。

3.2.1.2 散列编码

特征散列的目标就是是把原始的高维特征向量压缩成较低维特征向量,且尽量不损失原始特征的表达能力,其优势在于实现简单,所需额外计算量小。

降低特征维度,也能加速算法训练与预测,降低内存消耗,但代价是通过哈希转换后学习到的模型变得很难检验(因为一般哈希函数是不可逆的),我们很难对训练出的模型参数做出合理解释。特征散列的另一个问题是可能把多个原始特征哈希到相同的位置上,出现哈希冲突现象,但经验表明这种冲突对算法的精度影响很小,通过选择合适的 hash 函数也可以减少冲突概率。其实,每种程度的 hash 冲突也不一定是坏事,可能还可以提升模型的泛化能力。

3.2.2 连续特征构建

连续型特征的典型例子是用户年龄、统计类特征、物品的发布时间、影片的播放时长等数值型的特征。对于这类特征的处理,最常用的处理手段包括归一化、离散化、加非线性函数等方法。

归一化的主要目的是统一各特征的量纲,将连续特征归一化到 $[0, 1]$ 区间。也可以做 $0$ 均值归一化,即将原始数据集归一化为均值 $0$、方差 $1$ 的数据集。

离散化是通过确定分位数的形式将原来的连续值进行分桶,最终形成离散值的过程。离散化的主要目的是防止连续值带来的过拟合现象及特征值分布不均匀的情况。经过离散化处理的连续型特征和经过 ont-hot 处理的类别型特征一样,都是以特征向量的形式输入推荐模型的。

加非线性函数的处理方法,是直接把原来的特征通过非线性函数做变换,然后把原来的特征及变换后的特征一起加入模型进行训练的过程。常用的非线性函数包括 $x^a,log_a(x),log(\frac{x}{1-x})$ 等。

加非线性函数的目的是更好地捕获特征与优化目标之间的非线性关系,增强这个模型的非线性表达能力。

3.3 LGB 模型

面试官:能简单介绍一下 LGB 模型吗?

LightGBM(Light Gradient Boosting Machine)是一款基于决策树算法的分布式梯度提升框架。为了满足工业界缩短模型计算时间的需求,LightGBM 的设计思路主要是两点:

  1. 减小数据对内存的使用,保证单个机器在不牺牲速度的情况下,尽可能地用上更多的数据
  2. 减小通信的代价,提升多机并行时的效率,实现在计算上的线性加速

在一组实验中,LightGBM 比 XGBoost 快将近 $10$ 倍,内存占用率为 XGBoost 的 1/6,准确略也有提升。

3.3.1 直方图算法

LightGBM 使用的是直方图算法(histogram algorithm),占用的内存更低,数据分割的复杂度更低。直方图算法思想是:

  • 将连续的浮点特征离散成 $k$ 个离散值,并构造宽度为 $k$ 的直方图。
  • 遍历训练数据,统计每个离散值在直方图中的累计统计量。
  • 在进行特征选择时,只需要根据直方图的离散值,遍历寻找最优的分割点。

3.3.1.1 内存优化

直方图算法可以很大程度降低内存消耗,它不仅不需要额外存储预排序的结果,还可以只保存特征离散化后的值(一般用 $8$ 位整数存储就足够了)。

3.3.1.2 计算量优化

应用直方图算法,计算代价也大幅降低,预排序算法每遍历一个特征值就需要计算一次分裂的增益,而直方图算法只需要计算 $k$ 次($k$ 可以认为是常数),时间复杂度从 $O(\text{data}\times \text{feature})$ 直接优化到 $O(\text{k}\times \text{feature})$。

3.3.1.3 注意点

直方图算法的理解和注意点如下:

  • 使用分桶 bin 替代原始数据相当于增加了正则化。
  • 使用分桶 bin 意味着很多数据的细节特征丢失,相似的数据如果划分到相同的桶中,数据之间的差异就无法捕获了。
  • 分桶 bin 数量决定了正则化的程度,bin 越少惩罚越严重,欠拟合风险越高。
  • 因为预先设定了 bin 的范围,构建直方图时不需要对数据进行排序。
  • 直方图保存「划分阈值」、「当前 bin 内样本数」、「当前 bin 内所有样本的一阶梯度和」。
  • 阈值的选取是按照直方图从小到大遍历,使用了上面的一阶梯度和,目的是得到划分之后 $\triangle l o s s$ 最大的特征及阈值。

3.3.1.4 算法优缺点

Histogram 算法并不是完美的。由于特征被离散化后,找到的并不是很精确的分割点,所以会对结果产生影响。但在实际的数据集上表明,离散化的分裂点对最终的精度影响并不大,甚至会好一些。原因在于 decision tree 本身就是一个弱学习器,采用 Histogram 算法会起到正则化的效果,有效地防止模型的过拟合。

时间上的开销由原来的 $O(\text{data}\times \text{feature})$ 降到 $O(\text{k}\times \text{feature})$。由于离散化,$\#\text{bin}$ 远小于 $\# \text{data}$,因此时间上有很大的提升。

3.3.2 树的生长策略

直方图算法之上,LightGBM 进行进一步的优化。它没有使用大多数 GBDT 工具使用的按层生长(Level-wise)的决策树生长策略,而使用了带有深度限制的按叶子生长(Leaf-wise)算法。

$$
\left(p_{m}, f_{m}, v_{m}\right)=\arg \min _{(p, f, v)} L\left(T_{m-1}(X) \cdot \operatorname{split}(p, f, v), Y\right)
$$

$$
T_{m}(X)=T_{m-1}(X) \cdot \operatorname{split}\left(p_{m}, f_{m}, v_{m}\right)
$$

XGBoost 采用的是 Level-wise(按层生长)策略生长的,能够同时分裂同一层的叶子,从而进行多线程优化,不容易过拟合。但不加区分的对待同一层的叶子,带来了很多没必要的开销。因为实际上很多叶子的分裂增益较低,没必要进行搜索和分裂。

LightGBM 采用 Leaf-wise(按叶子生长)生长策略,每次从当前所有叶子中找到分裂增益最大(一般也是数据量最大)的一个叶子,然后分裂,如此循环。

同 Level-wise 相比,在分裂次数相同的情况下,Leaf-wise 可以降低更多的误差,得到更好的精度。Leaf-wise 的缺点是可能会长出比较深的决策树,产生过拟合。因此 LightGBM 在 Leaf-wise 之上增加了一个最大深度的限制,在保证高效率的同时防止过拟合。

3.3.3 直方图差加速

LightGBM 另一个优化是 Histogram (直方图)做差加速。整个构建过程中可以观察到:一个叶子的直方图可以由它的父亲节点的直方图与它兄弟的直方图做差得到。

一般来说构造直方图,需要遍历该叶子上的所有数据,但直方图做差仅需遍历直方图的 $k$ 个桶。利用上述特征,LightGBM 可以在构造一个叶子的直方图后,可以用非常微小的代价得到它兄弟叶子的直方图,在速度上可以提升一倍。

3.4 DIN 模型

面试官:DIN 模型是怎么做的?

DIN 网络结构整体上来看是在 Embedding 层与 MLP(全连接层)之间加入了 Activation Unit。从上图能够看出,用户历史点击过的商品 id(good id)只与候选广告的商品 id 算相关性(上图中是 element-wise 减,当然你也可以算内积等,甚至可以都算),用户历史点击过的商铺 id 只与候选广告的商铺 id 算相关性。

以下模块是整个 DIN 网络的核心模块,也是该模型的创新之处,如下图所示。

利用候选物品和用户历史行为之间的相关性计算出一个权重,这个权重就代表了“注意力”的强弱。可以看出,激活单元的输入层是 Embedding 向量,经过元素减(element-wise minus)操作后,与原 Embedding 向量一同连接后形成全连接层的输入,最后通过单神经元输出层生成注意力得分。

面试官:LGB 模型和 DIN 模型有什么区别?

  1. LGB 模型是一个机器学习模型,DIN 模型是一个深度学习模型;
  2. LGB 模型的运行效率要比 DIN 好很多,它做了很多工程上的性能优化;
  3. LGB 模型不涉及到推荐系统的领域知识,但是 DIN 模型是针对推荐的业务场景来进行设计的。

面试官:还有用过其他的模型吗?

3.5 SIM 模型

为了打破短期用户数据给推荐系统带来的局限性,能够个性化地建模用户需求,精准地刻画用户偏好,引入超长的用户行为数据。从全新的视角出发提出了一个两阶段搜索范式来建模用户的超长行为序列。

算法框架如图:

即 Genral Search Unit(GSU)和 Exact Search Unit(ESU)。GSU 将原始的用户行为从数万降低到数百,同时还过滤掉了和候选广告信息不相关的用户行为数据。在第二阶段,ESU 利用 GSU 产出的和广告相关的用户序列数据来捕捉用户跟广告更精准的兴趣表达。通俗一点讲,这里的思路很像广告推荐系统的召回和排序模块。召回模块是负责从候选广告中筛选出尽量相关的广告送给后续的排序模块进行处理,排序模块则是在此基础上将这部分候选广告按照点击率(或者说是 ECPM)来进行排序。

3.5.1 GSU 模块

GSU 做的工作很像广告推荐系统中召回模块的作用,一般多路召回会涉及到基于规则和策略的召回链路,同时也会包含基于 Embedding 的召回链路。在论文中提到 GSU 部分就主要借鉴了这两种召回的思路,提出了两种候选行为序列检索方法,即 hard-search 和 soft-search,前者可以认为是基于规则和策略的,后者可以认为是基于 Embedding 内积相似度的:

hard-search

从候选行为序列中按照给定规则筛选出与当前目标任务相关的候选集,论文中指出 hard-search 方法使用的是商品类别作为筛选的标准。hard-search 是无参数的。只有和候选广告类目相同的用户行为数据才会被选出送到下一级进行建模。$C_i$ 代表了第 $i$ 个用户行为的类目,$C_a$ 代表了候选广告类目。

soft-search

利用一个 DNN 模型来对每个候选行为序列进行建模,离线计算得到一个 embedding,然后将候选广告 embedding 和历史行为中的 embedding 算一个内积相似度,利用近似最近邻检索方法(论文中用的是 ALSH)来得到 TopK 相关的候选行为序列。

这种方法的缺点就是计算开销比较大,不如基于规则的 hard-search 方便,优点就是效果应该会更好一些。但是论文中也提到了两种方法在效果上的差异不是特别的大,所以最后基于性能和效果的折中,采用了 hard-search 这种比较简单的方式。上式 $W_a$ 和 $W_b$ 都是模型参数。其中 $e_a$ 代表了候选广告的 embedding,$e_i$ 代表了第 $i$ 个用户行为的 embedding。然后我们采用向量检索的方式来筛选出 TopK 和广告相关的用户行为。

3.5.2 ESU 模块

ESU 部分就是对 GSU 部分生成的较短的用户行为序列进行建模计算了,可以使用之前阿里这边提出的基于 Attention 的各种深层模型对用户行为序列进行 weighted sum-pooling,更好的提升 PCTR 预估的精确度。在 ESU 中,我们可以采用类似 DIN、DIEN、MIMN 这样复杂的模型来捕捉用户和广告相关的动态兴趣表达。

3.5.3 线上系统部署

考虑到离线效果提升和在线资源开销的性价比,将 hard-search 方式的 SIM 部署到在线广告系统。对于 hard-search,用户行为可以直接按照类目进行组织并建立好离线索引,使得在线检索时间消耗非常小。因此构建了一个两级的索引来组织用户行为,取名为 User Behavior Tree(UBT)。

UBT 采用 Key-Key-Value 数据结构来进行存储:第一级 key 是用户 ID,第二级 key 是叶子行为所属的类目。并采用广告类目作为 hard-search 检索 query,经过了 GSU 模块之后,将原始用户行为长度从上万数量级降低到百级。

3.6 缺失值的处理

面试官:如果数据中有缺失值,该怎么处理呢?

主要有两种做法:删除含有缺失数据的样本、对缺失值按照一定的策略进行填充。

3.6.1 不处理

  1. 补齐处理只是将未知值补以我们的主观估计值,不一定完全符合客观事实,在对不完备信息进行补齐处理的同时,或多或少地改变了原始的信息系统。
  2. 对空值不正确的填充往往将新的噪声引入数据中,使挖掘任务产生错误的结果。因此,在许多情况下,我们还是希望在保持原始信息不发生变化的前提下对信息系统进行处理。

但是训练模型的时候,可能不处理并不能进行。所以一般不会选择不处理。

3.6.2 特殊值填充

这个是认为数据的空值也是具有一定的信息的,它之所以为空,是因为它不同于其他的任何数据。所以将空值作为一种特殊的属性值来处理,它不同于其他的任何属性值。如所有的空值都用“unknown”或 $-1$ 填充。

3.6.3 平均值填充

  1. 如果空值是数值型的,就根据该属性在其他所有对象的取值的平均值来填充该缺失的属性值
  2. 如果空值是非数值型的,就根据统计学中的众数原理,用该属性在其他所有对象的取值次数最多的值(即出现频率最高的值)来补齐该缺失的属性值。

比方说,一个样本的特征 $a$ 缺失了,那么 $a$ 就填充上所有样本的特征 $a$ 的平均值。

此外有一种叫做条件平均值填充的方法,是只考虑和缺失样本具有相同特征的样本的平均值。比方说某一个样本的特征 $a$ 缺失了,用和这个样本的特征 $b$ 相同的所有样本的特征 $a$ 的平均值来填充这个缺失值。(因为这些样本和缺失数据的样本具有相同的特征,所有认为他们会更为相似)。

3.6.4 热卡填充

对于一个包含空值的对象,热卡填充法在完整数据中找到一个与它最相似的对象,然后用这个相似对象的值来进行填充。

【优缺点】

  • 优点:该方法概念上很简单,且利用了数据间的关系来进行空值估计
  • 缺点:在于难以定义相似标准,主观因素较多。

3.6.5 最近邻法

先根据欧式距离或相关分析来确定距离具有缺失数据样本最近的 $K$ 个样本,将这 $K$ 个值加权平均来估计该样本的缺失数据。

这个方法与热卡填充有些相似,如果最近邻法仅仅考虑最近的一个样本,那么就会退化成热卡填充。不过最近邻法和热卡填充面临同样的问题,如何衡量相似度。

3.7 树模型对于缺省值的处理

面试官:了解过树模型是如何对缺省值进行处理的吗?

3.7.1 C4.5

第一步,计算所有特征的信息增益或者信息增益率的时候,假设数据集一共 10000 个样本,特征 A 中缺失了 5000 个,则无视缺失值,在剩下的 5000 个特征中计算信息增益(或者信息增益率),最后乘以 0.5,思想就是缺失值多的特征通过这种降低权重的方式来体现信息的缺失;

第二步,如果运气不好,正好这个 A 特征乘 0.5 之后得到的信息增益或者增益率还是最大的,那么就像西瓜书中提到的那样,存在缺失值的样板按照比例进入分裂之后的新的分支,假设根据特征 A 分裂得到两个新的分支,一个分支有 2000 个样本,一个分支有 3000 个样本,则按照比例 2000 个缺失值和 3000 个缺失值样本分别进入两个分支。

3.7.2 CART

首先,如果某个存在缺失值的特征恰好是当前的分裂增益最大的特征,那么我们需要遍历剩余的特征,剩余的特征中如果有也存在缺失值的特征,那么这些特征忽略,仅仅在完全没有缺失值的特征上进行选择,选择其中能够与最佳增益的缺失特征分裂之后增益最接近的特征进行分裂。

如果事先设置了一定的标准仅仅选择仅仅选择差异性在一定范围内的特征作为代理特征进行分裂而导致了没有特征和最佳缺失特征的差异性满足要求,或者所有特征都存在缺失值的情况下,缺失样本默认进入个数最大的叶子节点。

显然这种缺失值的处理方式的计算量是非常大的,需要遍历其它的特征来进行代理特征选择,这个在数据量很大的情况下开销太大,而带来的性能提升确很有限,所以后来就不怎么用这种处理方式,

3.7.3 XGBoost

原文中关于缺失值的处理将其看与稀疏矩阵的处理看作一样。在寻找 split point 的时候,不会对该特征为 missing 的样本进行遍历统计,只对该列特征值为 non-missing 的样本上对应的特征值进行遍历,通过这个技巧来减少了为稀疏离散特征寻找 split point 的时间开销。

在逻辑实现上,为了保证完备性,会分别处理将 missing 该特征值的样本分配到左叶子结点和右叶子结点的两种情形,计算增益后选择增益大的方向进行分裂即可。可以为缺失值或者指定的值指定分支的默认方向,这能大大提升算法的效率。如果在训练中没有缺失值而在预测中出现缺失,那么会自动将缺失值的划分方向放到右子树。

3.8 RAPIDS cuDF

面试官:第二个比赛中的 RAPIDS cuDF 是什么?

RAPIDS 是一个开源的 GPU 加速 Python 库套件,旨在改进数据科学和分析流程。RAPIDS cuDF 是一个 GPU DataFrame 库,提供类似于 pandas 的 API,用于加载、过滤和操作数据。只需一个命令,就可以使用 cuDF 将加速计算引入 pandas 工作流程,而无需更改代码。基于处理 5GB 数据集的分析基准测试,可以将处理速度提高 150 倍。

要将 GPU 加速引入 Jupyter Notebook 中的 pandas 工作流程,请加载 cudf.pandas 扩展程序:

1
2
%load_ext cudf.pandas
import pandas as pd

要在运行 Python 脚本时访问它,请使用 cudf.pandas 模块选项:

1
python -m cudf.pandas script.py

面试官:AlphaZero 是什么样的一个算法?

balabala……

面试官:博弈比赛的神经网络参数量大概有多少?

  • Number of parameter: 3.08M
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
total = sum([param.nelement() for param in self.trainer.net_work.parameters()])
print("Number of parameter: %.2fM" % (total/1e6))

# ---------------------------------------------
NetWork(
(first_net): RestNet8B96C(
(conv): Conv2d(1, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(residues): Sequential(
(0): ResidualBlock(
(conv1): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn1): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(conv2): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn2): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
(1): ResidualBlock(
(conv1): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn1): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(conv2): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn2): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
(2): ResidualBlock(
(conv1): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn1): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(conv2): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn2): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
(3): ResidualBlock(
(conv1): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn1): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(conv2): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn2): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
(4): ResidualBlock(
(conv1): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn1): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(conv2): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn2): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
(5): ResidualBlock(
(conv1): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn1): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(conv2): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn2): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
(6): ResidualBlock(
(conv1): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn1): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(conv2): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn2): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
(7): ResidualBlock(
(conv1): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn1): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(conv2): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn2): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
)
(policy_head): PolicyHead(
(conv1): Conv2d(96, 48, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn1): BatchNorm2d(48, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(conv2): Conv2d(48, 1, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn2): BatchNorm2d(1, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
(value_head): ValueHead(
(conv1): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn1): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(conv2): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn2): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(fc): Linear(in_features=96, out_features=1, bias=True)
)
)
(second_net): RestNet8B96C(
(conv): Conv2d(1, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(residues): Sequential(
(0): ResidualBlock(
(conv1): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn1): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(conv2): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn2): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
(1): ResidualBlock(
(conv1): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn1): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(conv2): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn2): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
(2): ResidualBlock(
(conv1): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn1): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(conv2): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn2): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
(3): ResidualBlock(
(conv1): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn1): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(conv2): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn2): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
(4): ResidualBlock(
(conv1): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn1): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(conv2): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn2): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
(5): ResidualBlock(
(conv1): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn1): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(conv2): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn2): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
(6): ResidualBlock(
(conv1): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn1): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(conv2): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn2): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
(7): ResidualBlock(
(conv1): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn1): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(conv2): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn2): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
)
(policy_head): PolicyHead(
(conv1): Conv2d(96, 48, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn1): BatchNorm2d(48, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(conv2): Conv2d(48, 1, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn2): BatchNorm2d(1, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
(value_head): ValueHead(
(conv1): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn1): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(conv2): Conv2d(96, 96, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(bn2): BatchNorm2d(96, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU()
(fc): Linear(in_features=96, out_features=1, bias=True)
)
)
)

4 手撕代码

一共问了两道题,都做出来了,而且做的很好,面试官给出题目我就有了思路,然后思路也是正确的。写代码也是一次写好后直接运行就正确,挺幸运的。

4.1 三数之和

题目就是力扣上的15. 三数之和,之前自己做过这道题。主要区别是,力扣上的要求找出所有等于 $0$ 的三个数的组合,但是面试官说的是找出所有等于 $target$ 的三个数的组合,题目保证这个组合是唯一的。

直接开写,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <algorithm>
using namespace std;

// 下标从 1 开始
int nums[100] = { 0, 1, 4, 8, 0, 5 };

int main()
{
int target = 6, n = 5;
sort(nums + 1, nums + 1 + n);
for (int k = 1; k <= n; k++)
{
int tar = target - nums[k];
int i = k + 1, j = n;
while (i < j && nums[i] + nums[j] != tar)
{
if (nums[i] + nums[j] < tar) i ++;
else j --;
}
if (i != j)
{
cout << nums[k] << " " << nums[i] << " " << nums[j];
break;
}
}
return 0;
}

数组 $\text{nums}$ 的有效元素从下标 $1$ 开始,所以满足 $target=6$ 的三个数应该是 0、1 和 5。写完了执行之前其实还担心不对,但是运行后答案正确还是挺激动的。

4.2 数组排序

由于算法岗使用的编程语言主要是 Python,但是我在做第一道题的时候用的 C++,所以面试官说你能用 Python 把上面的数组 $\text{nums}$ 排个序吗?使用什么排序方法都行。

其实我早就开始用 Python 写算法题了,所以对我来说不成问题,选了最保险的 $O(n^2)$ 的排序算法。

当时一开始想写冒泡排序,如下:

1
2
3
4
5
6
7
8
9
10
In [3]: nums = [0, 1, 4, 8, 0, 5]
...: nums
Out[3]: [0, 1, 4, 8, 0, 5]

In [4]: for i in range(len(nums) - 1):
...: for j in range(i + 1, len(nums)):
...: if nums[i] > nums[j]:
...: nums[i], nums[j] = nums[j], nums[i]
...: nums
Out[4]: [0, 0, 1, 4, 5, 8]

结果是对的,但是后来一想,这好像不是冒泡排序,冒泡排序的流程如下:

每次交换相邻元素,但是我写的代码是每次交换 $\text{nums}[i]$ 和 $\text{nums}[j]$,所以我写的思路是:第一次把最小的移动第一个位置,第二次把第二小的移动到第二个位置,以此类推,能对就行。

冒泡排序的代码如下:

1
2
3
4
for i in range(len(nums) - 2, 0, -1):
for j in range(i + 1):
if nums[j] > nums[j + 1]:
nums[j], nums[j + 1] = nums[j + 1], nums[j]

5 总结

这次面试还是学到了很多东西,上面整理的内容肯定都是常考的,而且有的时候不止考察你对推荐算法的理解,还有很多实际的问题。但是自己在做的时候完全没有想过。

不同的模型感觉现在自己的理解还是停留在表面,希望能够打开这个算法黑箱,这样我觉得才有机会自己开发出新的算法。


6 参考资料

  1. 「推荐系统特征工程」05. 特征构建
  2. 图解机器学习 | LightGBM模型详解
  3. 九种缺失值处理方法
  4. 树模型们是如何处理缺失值的?
  5. 阿里SIM-基于检索的用户行为兴趣CTR模型
  6. RAPIDS cuDF 可将 pandas 加速近 150 倍,且无需更改代码
  7. 冒泡排序