0%

实验室实习

大二暑期的实验室实践课算是我第一次真正地参与实验室工作,由于专业课程还没有跟上,我对各个方向的了解也相当匮乏。根据一些道听途说的经验,我排除了已经相对完善的语音识别,过于内卷的计算机视觉,一夜之间被大模型颠覆的自然语言处理,选择了还不甚了解的强化学习。鉴于大家缺乏理论基础,老师安排我们以研讨会的形式学习David Silver的强化学习课程,剩余的时间再跟着学长开展工作。遗憾的是,学长给我分配的idea似乎很难奏效,当我确信这个idea缺乏创新性和实用性的时候,暑期实习已经接近尾声了,我在年底大致浏览了最新投稿的论文,也确实没有看到这方面的成果。现在想来,一个奏效的idea其实必须建立在自己深刻的理解之上,而非仅仅去尝试follow别人的工作,让缺乏指导经验的博士生带本科生其实也很不合理,无法实现循序渐进的有效培养。

一起实习的还有隔壁IEEE专业的卷王,他们申请了工位,每天泡在实验室里干活,甚至整个暑假都没有回家。这种勤奋给我留下了深刻的印象,也让我开始怀疑自己是否真的足够motivated,当然,我无法认同这种努力,我还是想给自己留出一段闲暇去陪一陪家人。相比那些忙于暑研的同学,我还是缺乏了一股狠劲,这也许就是我没能尽早产出论文的原因,然而在AI行业极度内卷的当下,即便有二作论文也未必会被认可。为了在申请留学时能够占据优势,每个人都急于在本科阶段发表顶会论文,崇尚潜心的学术变成了极尽功利的内卷,这恐怕是一种病态的追求。

锐评专业课

各种水课随着大三学年的到来烟消云散,只剩下几门核心专业课。《计算机视觉》和《自然语言处理》的授课以传统方法为主,而大作业却基于深度学习,难免有一些割裂感,但我仍能从经典算法中看到那个时代学者们理论的精妙和创新的智慧,感慨如今深度学习的如日中天和力大砖飞。IPADS实验室负责的《操作系统》则让我明显感受到课程设计的用心,从夏虞斌老师深入浅出的授课,到指导文档详实的实验作业,都为大家提供了极佳的学习体验,令人感叹不愧是世界顶尖的系统实验室。学术水平决定教学下限,思想态度决定教学上限,反观《生物信息学》,老师常年失踪,助教代理授课,一个研究生信的助教对着一群AI专业的学生讲机器学习,这种场景实在是有些好笑了。当然,作为一门水学分的课程,大家并不苛求能学到什么。《类脑智能》让我了解到脉冲神经网络和忆阻器计算的一些新方向,授课质量也算是中规中矩,而《人工智能前沿讲座》则是真正开拓眼界的一门课,能够在课堂上听到各路大佬分享学术见解和行业经验实在是弥足珍贵的,美中不足的是在时代的冲击下,讲座的主题清一色偏向于大语言模型,反而削减了领域的多样性。

令人窒息的大作业

对于期末的大作业很多这件事,我是早有耳闻的,但唯有亲身经历才知道其中的艰辛。几乎所有的专业课都在期末布置了大作业,这本来无可厚非,但《计算机视觉》这种已经安排期末考试的课程也布置大作业实在是令人费解,有些大作业既要提交报告,又要上台答辩,严重增加了同学们的负担。这些大作业大多以小组合作的形式进行,但小组合作恰恰是最为低效的作业形式,一个超过两人的团队往往至少有一个人全程摸鱼,甚至绝大部分的任务都是由一名成员完成。这种形式在一定程度上利好懒惰不作为的人,但对于勤勤恳恳干活的人却是极不公平的。

考虑到既然大作业是考核的一部分,那就不妨做得完美一些,每篇报告都按照论文的格式来写,刚好可以锻炼自己学术写作的能力和熟练度。值得一提是《计算机视觉》的大作业,我们自选了一个手写汉字生成的任务,这个任务恰好可以利用《机器学习工程实践》中与AI换脸类似的框架来实现,我在原有的结构上稍作改进,很好地完成了这个任务。由于负责数据集处理和实验评估的同学都相当靠谱,这个难度最高的大作业反而完成得相当顺利,算是非常愉快的一次小组合作。当我完成所有六个大作业时,已经是第十八周的周末了,我以前从未在作业进度上如此狼狈。

谈谈教育

寒假期间,杜大佬在水源社区发了一篇帖子《交大本科就是个大型PUA修罗场》,引发了大家的共鸣,他在帖中写道:

入学的时候,我起码是带着自信进校。开学典礼炫耀霸王课、挂科率、退学率,先给人当头一棒,除了恐吓人不知道能不能起到激励作用。你交讲“起点高,基础厚,要求严”传统,好像“我对你期望很高的,学不会都是你不努力”。通过各种“计划”、“试点班”筛选排名前X%,吃掉了所有的资源,让排名X%之后的同学觉得自己配不上交大,最终使得这些同学习惯了自己就是人下人,丧失了自信心和争取资源的主动性。当我以非前X%的成绩、没有任何“计划”、“试点班”帽子的状态毕业的时候,我觉得自己就配不上优秀,哪哪也比不上人。

诚然,我们的本科教育早已畸形,它不是在培养人才,而是在筛选人才。交大的公众号三天两头地推送一些先进个人的故事,以唯成就论的视角歌颂刻板而单一的评价体系,而每个人自我实现的价值是不能被世俗的标准所定义的,那些不被聚光灯照耀的交大人也在默默地努力,更需要具有人文关怀的赞美。当室友着手准备托福考试的时候,我猛然感受到时间的飞逝,方才意识到自己也极大地受到了这一套评价体系的影响,过于在乎所谓的GPA,却忽视了更长远的规划。我似乎已经在这个泥潭中内卷了许久,是时候向下一个阶段进发了。

一些改变

辛苦了两年半,在交大的学业压力终究要熬到头了。大三下的必修课仅有四门,加上另一门较水的通识,每周只有三天有课,我也终于可以腾出一些时间享受生活。疫情摧毁了我的运动习惯,大三以来也始终缺乏校园跑的监督,我清楚地感受到自己的身体状况和运动能力远不如高中时期,如今正是做出改变的时候。我开始尝试预约子衿街的健身房,麻雀虽小,却是五脏俱全,两块钱一小时的价格也昭示着学生福利的定位。作为新手的我并不知道如何使用这些器材,好在身边几个同行的同学有一些经验,手把手带着我感受发力,让我少走了很多弯路。我还记得第一次从健身房走出来时手臂的乏力感,尽管认真拉伸,那种酸痛也隐隐持续了两周之久。坚持两个月后,我逐渐形成了健身的习惯,也经历了逐步提升的过程,不过力量训练似乎对减重效果不显著,因此后来转向以有氧为主的健身。在同学的推荐下,我也开始玩一些CS2之类的游戏,在辛苦忙碌一天后,能够和同学玩两局,或是看一些职业比赛,也不失为一种有效的解压方式。

最后的几门课

由于大四上没有安排必修课程,所有的专业课都会在这学期完结。所有课程都以大作业的形式考核,因此这学期不存在期末考试,然而期末周并不如预想的那么轻松,单单《智能感知认知实践》一门课就有四个个人大作业,其余每门课程一个小组大作业,直到第十八周的最后几天我们才完善并提交了《虚拟现实》的大作业。《强化学习》主要介绍一些经典的算法,通过小作业的形式让大家建立理解,但大作业并非自选题目,偏向于算法探究而非实际运用,因此略显枯燥,对数据集解释的不明确甚至导致大家需要在展示前一天重跑实验,实在令人恼火。《数据挖掘》在今年突然改成了全英文授课的模式,内容上则主要介绍一些传统算法,可惜老师的英文表达能力比较有限,实际效果远远不如中文授课,对比之下方才令人感慨江波老师的《线性优化与凸优化》全英文授课水平之高。《智能感知认知实践》与此前的专业课内容过于重叠,因此几乎没有同学认真听课,大作业在内容上相当不错,对理解算法和提升能力都有帮助,但形式上存在优化空间。《虚拟现实》与游戏行业相关性较强,但我却不太感兴趣,因此也没有为大作业投入很多精力。从结果看来,四门专业课的成绩都差强人意,但学积分却仍然排在第一,看来大家也并不想在最后几门课上做低效的内卷。

企业实习

享受了两个月的悠闲生活,我猛然发现暑期实习近在咫尺了,由于在国智班的缘故,我提前申请了上海人工智能实验室的实习,在五月中旬便入职,开始了忙碌的打工生活。有趣的是,所谓打工其实就是科研,工作产出与个人利益完全一致,相比在学校给导师免费干活,拿到的这笔工资实在是物超所值。我相当欣赏实验室的科研品味,他们强调工作的影响力和长期价值,并愿意在这些工作上投入大量成本,这才是推动国家科学技术进步的核心动力。身边的同事有联培项目的博士生,也有毕业入职的研究员,他们对行业和技术都有自己独到的见解,在当下也有不错的工作待遇。时代形式瞬息万变,希望当我毕业之时,仍能处在行业风口,享受到一些红利。

入职实习以后,我的生活成本也显著提高了,从闵行到徐汇,消耗在通勤上的时间接近三小时。徐汇的物价颠覆了我长期在校内建立的认知,三四十块一顿饭的价格在高楼林立的商业区竟然显得如此合理。当然,附近大多是互联网大厂的员工,完全符合这里的消费水平。几周以后,我也逐渐适应了这种物价,仿佛这种生活才配得上所谓国际大都市的名号,而位置偏远的闵行真有一种乡下村落的感觉了。

关于焦虑

未来的不确定性随着时间的推移逐渐褪去,我开始感受到一种前所未有的焦虑,这种焦虑其实一直在堆积,只是在前两年的学业压力下无暇被察觉,当埋头行走的生活来到抬头展望的岔路口,那些来自父母的希冀,来自同辈的压力,对于自我的期许,对于未来的不安相互交叠,让我有些难以入眠。在其他人眼中,我可能是所谓的大佬和最无需担心未来的人,但是关于即将到来的选择,我也仅停留于一些初步的构想。我认识的同学中,有些人在实验室刻苦工作,在顶会上发表了论文,有些人早已联系机构,确定了留学规划,而我在自己局限的视野里跌跌撞撞,空有一纸学业成绩而无法变现。

我在煎熬中不断尝试自我辩驳,终究摆脱了无意义的自我内耗,我领悟到,破除焦虑的过程就是与自我和解的过程。众生芸芸,出身富贵者有之,天资卓著者有之,勤勉过人者有之,在任何领域总是人外有人,追求完满本就是不现实的。我并不贪图乘风破浪的快意,但求平定安稳的清欢,既然如此,就不必凡事都与他人相比,只需知天命尽人事,在可预见的范围内做最好的选择便可。

写在最后

当我站在凯旋门前,迎接又一批新生到来时,我已经是这个校园里最老的本科生之一了,看着他们青涩的模样,我仿佛可以窥见未来的三年:不断认识世界,不断了解自我,不断做出选择。当我在推免意向中选择放弃的那一刻,世界线便开始收束,所有的可能性归于一种必然,恍然如梦。所有曾在这段旅程中或是一帆风顺或是困顿踌躇的人,都已经在冥冥中做出了自己的选择,也祝愿读到这里的人,无论在人生的哪个阶段,能够驱除迷惘,诸事顺遂。

安装应用

Windows 11 自带 Windows Terminal,如果使用 Windows 10 需要先在 Microsoft Store 手动安装

最新版本的 oh-my-posh 也可以直接通过 Microsoft Store 安装,注意下载可能需要代理

配置字体

我们首先需要安装 Nerd Fonts 以确保 oh-my-posh 中的字符和图标能够正常显示

各种字体可以直接在官网下载,这里我们选用 CaskaydiaCove Nerd Font 作为演示,下载并安装字体后打开 Windows Terminal,在设置中选择 Windows PowerShell 的配置文件,在外观中修改字体并保存即可

修改执行策略

此外,我们需要修改 Windows PowerShell 的执行策略,使得 oh-my-posh 能够正确执行,以管理员身份运行 Windows PowerShell 执行命令

1
$ set-ExecutionPolicy RemoteSigned

然后根据提示,将执行策略修改为是或全是

创建配置文件

现在我们为 Windows PowerShell 创建配置文件,在每次启动时都对 oh-my-posh 进行初始化

1
$ New-Item $profile -Value "oh-my-posh init pwsh | Invoke-Expression" -Force

重新打开 Windows Terminal,此时 oh-my-posh 已经可以正常启动

更改主题

接下来我们可以按需更换主题,以 Powerlevel10k 的 Rainbow Style 为例,首先执行命令

1
$ Get-PoshThemes

该命令会列出所有主题及其所在路径,根据提示我们可以定位到 themes 目录,将相应的主题文件复制到用户目录下

1
$ Copy-Item "C:\Users\xxyQwQ\AppData\Local\Programs\oh-my-posh\themes\powerlevel10k_rainbow.omp.json" -Destination "C:\Users\xxyQwQ" -Force

根据提示修改创建的配置文件,在启动时加载主题

1
2
$ code $profile
oh-my-posh init pwsh --config "C:\Users\xxyQwQ\powerlevel10k_rainbow.omp.json" | Invoke-Expression

我们可以对主题文件进行定制化修改,例如添加 Anaconda 环境的显示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ code "C:\Users\xxyQwQ\powerlevel10k_rainbow.omp.json"
{
"background": "#FFDE57",
"foreground": "#111111",
"invert_powerline": true,
"powerline_symbol": "\ue0b2",
"properties": {
"fetch_virtual_env": true,
"fetch_version": false
},
"style": "powerline",
"template": " {{ if .Error }}{{ .Error }}{{ else }}{{ if .Venv }}{{ .Venv }}{{ end }}{{ end }} \ue235 ",
"type": "python"
},

保存并重启 Windows Terminal,此时主题已经加载成功

安装模块

为了获得与 zsh 类似的使用体验,我们还需要额外安装一些模块,以管理员身份运行 Windows PowerShell 执行命令

1
2
$ Install-Module -Name PackageManagement -Repository PSGallery -Force
$ Install-Module -Name PowerShellGet -Repository PSGallery -Force

重启 Windows PowerShell,同样以管理员身份执行命令

1
2
$ Install-Module -Name PSReadLine -AllowPrerelease -Scope CurrentUser -Force -SkipPublisherCheck
$ Install-Module -Name posh-git -AllowPrerelease -Scope CurrentUser -Force -SkipPublisherCheck

模块安装完成后,在配置文件中添加如下命令

1
2
3
4
5
$ code $profile
Import-Module posh-git
Import-Module PSReadLine
Set-PSReadLineOption -PredictionSource History
Set-PSReadLineKeyHandler -Chord "Ctrl+RightArrow" -Function ForwardWord

保存并重启 Windows Terminal,此时模块已经加载成功,此外 oh-my-posh 可以根据输入历史补全命令,使用 Ctrl+RightArrow 可以补全单词

个性化设置

此外,我们也可以在配置文件中添加命令以支持更多个性化设置,例如

1
Set-Alias ll ls

从而使得 oh-my-posh 更加符合 Linux 用户的使用习惯,一个完整的配置文件示例如下

1
2
3
4
5
6
7
$ code $profile
oh-my-posh init pwsh --config "C:\Users\xxyQwQ\powerlevel10k_rainbow.omp.json" | Invoke-Expression
Import-Module posh-git
Import-Module PSReadLine
Set-PSReadLineOption -PredictionSource History
Set-PSReadLineKeyHandler -Chord "Ctrl+RightArrow" -Function ForwardWord
Set-Alias ll ls

配置 VSCode 终端

最后,我们在 VSCode 中将默认配置文件改为 Windows PowerShell,并将终端字体改为 ‘CaskaydiaCove Nerd Font Mono’

至此,我们已经可以在 VSCode 中正常使用 oh-my-posh 作为终端

写在前面

VSCode推出Remote SSH插件以后,远程开发变得简单,原生的开发环境大大提高了工作效率,但各种复杂的系统交互仍然需要回归终端。趁着近期有空折腾,笔者搜集各种资料,基于zsh+oh-my-zsh+tmux+oh-my-tmux搭建了一个相对令人满意的终端环境,本文用于整理搭建过程,构建一份搭建指南,以便日后读者参考

环境要求

  • Linux操作系统(笔者使用Ubuntu 20.04,其他版本可能略有差异)
  • 用户具有sudo权限(安装依赖)
  • 一些基本的工具(例如curlgit等)
  • 科学的网络环境(请自行配置代理工具)

搭建步骤

zsh

使用apt直接安装zsh

1
$ sudo apt install zsh

检查zsh可用性

1
2
3
$ cat /etc/shells
# /etc/shells: valid login shells
/usr/bin/zsh

oh-my-zsh

使用curl安装oh-my-zsh,这里同样可以使用wget,具体参考官网

1
2
$ cd
$ sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"

修改默认shellzsh,然后重新登录

1
$ chsh -s /bin/zsh

检查默认shell是否已经正确修改

1
2
$ echo $SHELL
/bin/zsh

注意:如果使用VSCode进行远程开发,需要额外进行以下操作

  • 在命令面板(Ctrl+Shift+P)中关闭服务器上的vscode-server进程
  • 进入配置终端设置,将该服务器的默认终端修改为zsh

此处笔者使用agnoster主题,在~/.zshrc中修改配置

1
2
$ vim ~/.zshrc
ZSH_THEME="agnoster"

Github安装zsh-autosuggestionszsh-syntax-highlighting插件,并在~/.zshrc中启用

1
2
3
4
$ git clone https://github.com/zsh-users/zsh-autosuggestions $ZSH_CUSTOM/plugins/zsh-autosuggestions
$ git clone https://github.com/zsh-users/zsh-syntax-highlighting.git $ZSH_CUSTOM/plugins/zsh-syntax-highlighting
$ vim ~/.zshrc
plugins=(git zsh-autosuggestions zsh-syntax-highlighting)

tmux

使用apt直接安装tmux

1
$ sudo apt install tmux

如果系统自带tmux,重启即可

1
$ tmux kill-server

oh-my-tmux

Github安装oh-my-tmux,这里笔者使用自己配置的版本,同样可以直接使用原版,除了下载地址不同以外,安装过程完全相同

1
2
3
4
$ cd
$ git clone https://github.com/xxyQwQ/.tmux
$ ln -sf .tmux/.tmux.conf
$ cp .tmux/.tmux.conf.local .

其他事项

迁移bash配置

如果之前在.bashrc中进行了一些配置,可以将这些内容直接复制到.zshrc

1
2
3
4
5
$ vim ~/.zshrc
# proxy setting
if [ -f /etc/profile.d/clash.sh ]; then
. /etc/profile.d/clash.sh
fi

关于Anaconda

如果使用Anaconda,建议关闭自动激活,否则在启动tmuxPATH变量可能错误加载,导致虚拟环境无法正常使用

1
2
$ vim ~/.condarc
auto_activate_base: false

改进外观

笔者配置时注意到zshtmux中显示的主机名可能很长,可以通过修改配置文件的方式隐藏它们

对于oh-my-zsh,在agnoster.zsh-theme中做如下修改

1
2
3
4
5
6
$ vim ~/.oh-my-zsh/themes/agnoster.zsh-theme
prompt_context() {
if [[ "$USERNAME" != "$DEFAULT_USER" || -n "$SSH_CLIENT" ]]; then
prompt_segment black default "%(!.%{%F{yellow}%}.)%n"
fi
}

对于oh-my-tmux,在~/.tmux.conf.local中做如下修改

1
2
$ vim ~/.tmux.conf.local
tmux_conf_theme_status_right=" #{prefix}#{mouse}#{pairing}#{synchronized}#{?battery_status,#{battery_status},}#{?battery_bar, #{battery_bar},}#{?battery_percentage, #{battery_percentage},} , %R , %d %b | #{username}#{root} "

至此,终端环境已经搭建完成,效果展示如下

写在前面

这篇文章诞生于机器学习课程无聊的大作业,既然已经为此浪费了不少时间,不妨再多花点时间写一篇文章,借此记录一下实现过程。支持向量机的数学形式简约而直观,但一旦涉及具体实现,各种问题就会接踵而来。在本文中,笔者将首先推导SVM的主要公式,接着基于Platt-SMO算法,从零开始实现支持多种核函数的SVM,然后基于One-Versus-One策略实现多分类,最后在MNIST和CIFAR-10数据集上进行性能测试

数学推导

基本形式

给定一个训练集 $\mathcal{D} = \{(\boldsymbol{x}_i, y_i)\}_{i=1}^m$,其中 $\boldsymbol{x}_i \in \mathbb{R}^n$ 是特征向量,$y_i \in \{+1, -1\}$ 是标签。线性SVM期望找到一个超平面 $\boldsymbol{w}^T \boldsymbol{x} + b = 0$,将正样本和负样本分开,其中 $\boldsymbol{w} \in \mathbb{R}^n$ 是法向量,$b \in \mathbb{R}$ 是偏移量

对于任一样本 $\boldsymbol{x}_i$,其到超平面的距离为

假定该超平面能将正负样本完全分开,即对于任一样本 $\boldsymbol{x}_i$有

注意到有一些样本满足 $\boldsymbol{w}^T \boldsymbol{x}_i + b = \pm 1$,它们被称为支持向量。支持向量到超平面的距离被称为间隔,记为

我们的目标是最大化间隔,即最小化 $\left|\boldsymbol{w}\right|$。因此,优化问题可以表述为

然而,这些样本并不总是线性可分的,因此我们引入Hinge损失函数

于是,优化问题可以表述为

如果我们引入松弛变量 $\xi_i \geq 0$,优化问题可以改写为

为了构造对偶问题,我们引入拉格朗日乘子 $\alpha_i \geq 0$ 和 $\mu_i \geq 0$,拉格朗日函数定义为

令 $\mathcal{L}$ 对 $\boldsymbol{w}$,$b$ 和 $\xi_i$ 的偏导数为零,可得

因此,对偶问题可以表述为

这是一个二次规划问题,我们可以采用梯度下降或者坐标下降等方法求解。

核函数与核技巧

有时候这些样本并不是线性可分的,但是我们可以将它们映射到高维空间,使得它们在高维空间中线性可分。假定映射函数为 $\phi$,则样本 $\boldsymbol{x}_i$ 被映射到 $\phi(\boldsymbol{x}_i)$。我们可以将对偶问题改写为

有趣的是,我们并不需要显式地计算 $\phi(\boldsymbol{x}_i)$,而是通过核函数 $K(\boldsymbol{x}_i, \boldsymbol{x}_j) = \phi(\boldsymbol{x}_i)^T \phi(\boldsymbol{x}_j)$ 来计算内积,因此对偶问题可以改写为

这种方法也称为核技巧(Kernel Trick),下面我们给出几种常用的核函数

核函数 $\kappa(\boldsymbol{x}_i, \boldsymbol{x}_j)$
线性核 $\boldsymbol{x}_i^T \boldsymbol{x}_j$
多项式核 $(\gamma \boldsymbol{x}_i^T \boldsymbol{x}_j + r)^d$
高斯核 $\exp(-\gamma \vert\boldsymbol{x}_i - \boldsymbol{x}_j\vert^2)$
Sigmoid核 $\tanh(\gamma \boldsymbol{x}_i^T \boldsymbol{x}_j + r)$

Platt-SMO算法

Platt-SMO算法来源于坐标下降法,每次只尝试优化一个变量。由于对偶问题中存在约束,我们每次需要优化两个变量。假设我们选择 $\alpha_1$ 和 $\alpha_2$ 来优化,固定其他变量,优化问题可以表述为

其中 $\zeta$ 是一个常数,我们可以将 $\alpha_1$ 表示为

决策函数可以表示为

令 $E_i=f(\boldsymbol{x}_i)-y_i$ 表示预测值与真实值之间的误差。定义辅助变量 $v_1$ 和 $v_2$ 为

于是目标函数可以写成

令 $\mathcal{L}$ 对 $\alpha_2$ 的偏导数为零,可得

令 $\eta = \kappa(\boldsymbol{x}_1, \boldsymbol{x}_1) + \kappa(\boldsymbol{x}_2, \boldsymbol{x}_2) - 2 \kappa(\boldsymbol{x}_1, \boldsymbol{x}_2)$,注意到

于是有

因此

由于存在约束条件 $0 \leq \alpha_1,\alpha_2 \leq C$,因此需要对 $\alpha_2^*$ 进行修剪

其中

于是 $\alpha_1$ 可以通过 $\alpha_2$ 来计算

如果 $0 < \alpha_i < C$,则 $\boldsymbol{x}_i$ 是支持向量,辅助变量 $b_1$ 和 $b_2$ 定义为

因此,偏移量 $b$ 的更新规则为

现在我们已经得到了单次迭代中所有参数的更新公式,我们只需要反复地选择一对 $\alpha_i$ 和 $\alpha_j$ 进行更新,直到收敛为止

算法实现

核函数

我们对上述四种核函数进行实现,这里将核函数封装成类,通过实现__call__方法,使其实例可以像函数一样被调用

  1. 线性核

    1
    2
    3
    4
    5
    6
    class LinearKernel(object):
    def __init__(self):
    self.name = 'linear'

    def __call__(self, X, y):
    return X @ y.T
  2. 多项式核

    1
    2
    3
    4
    5
    6
    7
    8
    class PolynomialKernel(object):
    def __init__(self, gamma=1.0, degree=3):
    self.name = 'polynomial'
    self.gamma = gamma
    self.degree = degree

    def __call__(self, X, y):
    return np.power(self.gamma * (X @ y.T) + 1, self.degree)
  3. 高斯核

    1
    2
    3
    4
    5
    6
    7
    class GaussianKernel(object):
    def __init__(self, gamma=1.0):
    self.name = 'gaussian'
    self.gamma = gamma

    def __call__(self, X, y):
    return np.exp(-self.gamma * np.sum(np.square(X - y), axis=1))
  4. Sigmod核

    1
    2
    3
    4
    5
    6
    7
    8
    class SigmoidKernel(object):
    def __init__(self, gamma=1.0, bias=0.0):
    self.name = 'sigmoid'
    self.gamma = gamma
    self.bias = bias

    def __call__(self, X, y):
    return np.tanh(self.gamma * (X @ y.T) + self.bias)

另外,我们定义一个工具函数,方便核函数的创建

1
2
3
4
5
6
7
8
9
10
def CreateKernel(entry):
if entry['name'] == 'linear':
return LinearKernel()
elif entry['name'] == 'polynomial':
return PolynomialKernel(entry['gamma'], entry['degree'])
elif entry['name'] == 'gaussian':
return GaussianKernel(entry['gamma'])
elif entry['name'] == 'sigmoid':
return SigmoidKernel(entry['gamma'], entry['bias'])
raise AttributeError('invalid kernel')

支持向量机

参考scikit-learn的封装,我们定义一个类,提供fitpredict两种方法,参数包括最大迭代次数、惩罚系数、误差精度和核函数类型,利用私有函数实现 $\alpha_i$ 和 $\alpha_j$ 的选择和单步更新,对于线性核,我们提供weight属性,用于获取线性核的分类超平面参数,除了一些简化以外,代码基本按照Platt-SMO算法进行实现

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
class SupportVectorMachine(object):
def __init__(self, iteration=100, penalty=1.0, epsilon=1e-6, kernel=None):
self.iteration = iteration
self.penalty = penalty
self.epsilon = epsilon
if kernel is None:
kernel = {'name': 'linear'}
self.kernel = CreateKernel(kernel)

def __compute_w(self):
return (self.a * self.y) @ self.X

def __compute_e(self, i):
return (self.a * self.y) @ self.K[:, i] + self.b - self.y[i]

def __select_j(self, i):
j = np.random.randint(1, self.m)
return j if j > i else j - 1

def __step_forward(self, i):
e_i = self.__compute_e(i)
if ((self.a[i] > 0) and (e_i * self.y[i] > self.epsilon)) or ((self.a[i] < self.penalty) and (e_i * self.y[i] < -self.epsilon)):
j = self.__select_j(i)
e_j = self.__compute_e(j)
a_i, a_j = np.copy(self.a[i]), np.copy(self.a[j])
if self.y[i] == self.y[j]:
L = max(0, a_i + a_j - self.penalty)
H = min(self.penalty, a_i + a_j)
else:
L = max(0, a_j - a_i)
H = min(self.penalty, self.penalty + a_j - a_i)
if L == H:
return False
d = 2 * self.K[i, j] - self.K[i, i] - self.K[j, j]
if d >= 0:
return False
self.a[j] = np.clip(a_j - self.y[j] * (e_i - e_j) / d, L, H)
if np.abs(self.a[j] - a_j) < self.epsilon:
return False
self.a[i] = a_i + self.y[i] * self.y[j] * (a_j - self.a[j])
b_i = self.b - e_i - self.y[i] * self.K[i, i] * (self.a[i] - a_i) - self.y[j] * self.K[j, i] * (self.a[j] - a_j)
b_j = self.b - e_j - self.y[i] * self.K[i, j] * (self.a[i] - a_i) - self.y[j] * self.K[j, j] * (self.a[j] - a_j)
if 0 < self.a[i] < self.penalty:
self.b = b_i
elif 0 < self.a[j] < self.penalty:
self.b = b_j
else:
self.b = (b_i + b_j) / 2
return True
return False

def setup(self, X, y):
self.X, self.y = X, y
self.m, self.n = X.shape
self.b = 0.0
self.a = np.zeros(self.m)
self.K = np.zeros((self.m, self.m))
for i in range(self.m):
self.K[:, i] = self.kernel(X, X[i, :])

def fit(self, X, y):
self.setup(X, y)
entire = True
for _ in range(self.iteration):
change = 0
if entire:
for i in range(self.m):
change += self.__step_forward(i)
else:
index = np.nonzero((0 < self.a) * (self.a < self.penalty))[0]
for i in index:
change += self.__step_forward(i)
if entire:
entire = False
elif change == 0:
entire = True

def predict(self, X):
m = X.shape[0]
y = np.zeros(m)
for i in range(m):
y[i] = np.sign((self.a * self.y) @ self.kernel(self.X, X[i, :]) + self.b)
return y

@property
def weight(self):
if self.kernel.name != 'linear':
raise AttributeError('non-linear kernel')
return self.__compute_w(), self.b

多分类

基于One-Versus-One策略,我们构造 $C_k^2$ 个SVM,其中 $k$ 为类别数,训练每个分类器时,选取相应类别的样本作为训练集,并将标签映射到 $-1$ 和 $1$,在预测时,用每个分类器的预测结果进行投票,从而得到最终结果

我们采用与支持向量机完全相同的封装,提供fitpredict两种方法,使该类成为通用的分类模型

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
class SupportVectorClassifier(object):
def __init__(self, iteration=100, penalty=1.0, epsilon=1e-6, kernel=None):
self.iteration = iteration
self.penalty = penalty
self.epsilon = epsilon
self.kernel = kernel
self.classifier = []

def __build_model(self, y):
self.label = np.unique(y)
for i in range(len(self.label)):
for j in range(i+1, len(self.label)):
model = SupportVectorMachine(self.iteration, self.penalty, self.epsilon, self.kernel)
self.classifier.append((i, j, model))

def fit(self, X, y):
self.__build_model(y)
for i, j, model in tqdm(self.classifier):
index = np.where((y == self.label[i]) | (y == self.label[j]))[0]
X_ij, y_ij = X[index], np.where(y[index] == self.label[i], -1, 1)
model.fit(X_ij, y_ij)

def predict(self, X):
vote = np.zeros((X.shape[0], len(self.label)))
for i, j, model in tqdm(self.classifier):
y = model.predict(X)
vote[np.where(y == -1)[0], i] += 1
vote[np.where(y == 1)[0], j] += 1
return self.label[np.argmax(vote, axis=1)]

性能测试

首先,我们在二维平面上构造两组简单的正态分布数据,用于可视化支持向量机的分类效果,首先构造数据并训练模型

1
2
3
4
5
6
7
X = np.concatenate((np.random.randn(500, 2) - 2, np.random.randn(500, 2) + 2))
y = np.concatenate((np.ones(500), -np.ones(500)))
C = SupportVectorMachine(iteration=100)
C.fit(X, y)
w, b = C.weight
u = np.linspace(-3, 3, 100)
v = (-b - w[0] * u) / w[1]

然后根据模型参数绘制分类效果

1
2
3
4
5
6
7
8
9
10
11
plt.scatter(X[:500, 0], X[:500, 1], label='Positive')
plt.scatter(X[500:, 0], X[500:, 1], label='Negative')
plt.plot(u, v, label='Separation', c='g')
plt.xlabel('$x$')
plt.ylabel('$y$')
plt.title('Separation Sample')
plt.grid()
plt.legend()
plt.tight_layout()
plt.savefig('./figure/separation.png')
plt.show()

可以看到,我们实现的SVM可以很好地将两组数据分开

为了在MNIST和CIFAR-10数据集上测试性能,需要对数据进行预处理,这里我们将图像展开为向量,并将像素值归一化到 $[0, 1]$ 区间,对于MNIST数据集我们仅保留 $5000$ 个训练样本和 $1000$ 个测试样本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def MNIST(path, group='train'):
if group == 'train':
with gzip.open(os.path.join(path, 'train-images-idx3-ubyte.gz'), 'rb') as file:
image = np.frombuffer(file.read(), np.uint8, offset=16).reshape(-1, 1, 28, 28) / 255.0
with gzip.open(os.path.join(path, 'train-labels-idx1-ubyte.gz'), 'rb') as file:
label = np.frombuffer(file.read(), np.uint8, offset=8)
elif group == 'test':
with gzip.open(os.path.join(path, 't10k-images-idx3-ubyte.gz'), 'rb') as file:
image = np.frombuffer(file.read(), np.uint8, offset=16).reshape(-1, 1, 28, 28) / 255.0
with gzip.open(os.path.join(path, 't10k-labels-idx1-ubyte.gz'), 'rb') as file:
label = np.frombuffer(file.read(), np.uint8, offset=8)
remain = 500 if group == 'train' else 100
image_list, label_list = [], []
for value in range(10):
index = np.where(label == value)[0][:remain]
image_list.append(image[index])
label_list.append(label[index])
image, label = np.concatenate(image_list), np.concatenate(label_list)
index = np.random.permutation(len(label))
return image[index], label[index]

对于CIFAR10数据集,我们做同样的处理

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
def CIFAR10(path, group='train'):
if group == 'train':
image_list, label_list = [], []
for i in range(1, 6):
filename = os.path.join(path, 'data_batch_{}'.format(i))
with open(filename, 'rb') as file:
data = pickle.load(file, encoding='bytes')
image_list.append(np.array(data[b'data'], dtype=np.float32).reshape(-1, 3, 32, 32) / 255.0)
label_list.append(np.array(data[b'labels'], dtype=np.int32))
image, label = np.concatenate(image_list), np.concatenate(label_list)
elif group == 'test':
filename = os.path.join(path, 'test_batch')
with open(filename, 'rb') as file:
data = pickle.load(file, encoding='bytes')
image = np.array(data[b'data'], dtype=np.float32).reshape(-1, 3, 32, 32) / 255.0
label = np.array(data[b'labels'], dtype=np.int32)
remain = 500 if group == 'train' else 100
image_list, label_list = [], []
for value in range(10):
index = np.where(label == value)[0][:remain]
image_list.append(image[index])
label_list.append(label[index])
image, label = np.concatenate(image_list), np.concatenate(label_list)
index = np.random.permutation(len(label))
return image[index], label[index]

由于CIFAR10数据集较为困难,我们考虑利用CV方法进行特征提取,这里我们使用HOG特征提高分类效果,首先将彩色图像转换为灰度图像

1
2
3
def RGB2Gray(image):
image = 0.299 * image[0] + 0.587 * image[1] + 0.114 * image[2]
return image.reshape(1, *image.shape)

然后实现一个简单的HOG特征提取函数,这里我们没有实现区块重叠,对该函数进行改进应该可以进一步提高分类效果

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
def HOG(image, block=4, partition=8):
image = RGB2Gray(image).squeeze(axis=0)
height, width = image.shape
gradient = np.zeros((2, height, width), dtype=np.float32)
for i in range(1, height-1):
for j in range(1, width-1):
delta_x = image[i, j-1] - image[i, j+1]
delta_y = image[i+1, j] - image[i-1, j]
gradient[0, i, j] = np.sqrt(delta_x ** 2 + delta_y ** 2)
gradient[1, i, j] = np.degrees(np.arctan2(delta_y, delta_x))
if gradient[1, i, j] < 0:
gradient[1, i, j] += 180
unit = 360 / partition
vertical, horizontal = height // block, width // block
feature = np.zeros((vertical, horizontal, partition), dtype=np.float32)
for i in range(vertical):
for j in range(horizontal):
for k in range(block):
for l in range(block):
rho = gradient[0, i*block+k, j*block+l]
theta = gradient[1, i*block+k, j*block+l]
index = int(theta // unit)
feature[i, j, index] += rho
feature[i, j] /= np.linalg.norm(feature[i, j]) + 1e-6
return feature.reshape(-1)

基于这些工具函数,我们可以优雅地完成图像分类任务,对于MNIST数据集,一个基于线性核的分类示例如下

1
2
3
4
5
6
7
8
9
10
X_train, y_train = MNIST('./dataset/mnist_data/', group='train')
X_test, y_test = MNIST('./dataset/mnist_data/', group='test')
X_train, X_test = X_train.reshape(-1, 28*28), X_test.reshape(-1, 28*28)

model = SupportVectorClassifier(iteration=100, penalty=0.05)
model.fit(X_train, y_train)
p_train, p_test = model.predict(X_train), model.predict(X_test)

r_train, r_test = ComputeAccuracy(p_train, y_train), ComputeAccuracy(p_test, y_test)
print('Kernel: Linear, Train: {:.2%}, Test: {:.2%}'.format(r_train, r_test))

对于CIFAR10数据集,一个基于HOG特征和高斯核的分类示例如下

1
2
3
4
5
6
7
8
9
10
11
X_train, y_train = CIFAR10('./dataset/cifar-10-batches-py/', group='train')
X_test, y_test = CIFAR10('./dataset/cifar-10-batches-py/', group='test')
X_train, X_test = BatchHOG(X_train, partition=16), BatchHOG(X_test, partition=16)

kernel = {'name': 'gaussian', 'gamma': 0.03}
model = SupportVectorClassifier(iteration=100, kernel=kernel)
model.fit(X_train, y_train)
p_train, p_test = model.predict(X_train), model.predict(X_test)

r_train, r_test = ComputeAccuracy(p_train, y_train), ComputeAccuracy(p_test, y_test)
print('Kernel: Gaussian, Train: {:.2%}, Test: {:.2%}'.format(r_train, r_test))

经过测试,我们实现的SVM分类器在MNIST和CIFAR10数据集上的分类精度如下表所示

核函数 组别 MNIST CIFAR10 CIFAR10-HOG
线性核 训练集 96.96% 76.14% 77.58%
测试集 90.70% 33.60% 39.50%
多项式核 训练集 100.00% 99.86% 100.00%
测试集 94.00% 37.70% 44.10%
高斯核 训练集 99.68% 99.14% 94.54%
测试集 94.80% 34.10% 47.00%
Sigmoid核 训练集 95.42% 7.96% 59.82%
测试集 92.10% 7.40% 44.70%

此外,我们对模型的收敛性和各个核函数的参数选择进行了测试,模型精度与迭代次数的关系如下图所示

线性核分类精度与 $C$ 和 $\varepsilon$ 的关系如下图所示

多项式核分类精度与 $\gamma$ 和 $d$ 的关系如下图所示

高斯核分类精度与 $C$ 和 $\gamma$ 的关系如下图所示

Sigmoid核分类精度与 $\gamma$ 和 $r$ 的关系如下图所示

上述结果揭示了各个参数对模型性能的影响,可以为调参提供一定的指导作用

写在最后

SVM从过去的炙手可热到如今的日薄西山,仅仅过去了十年的时间,无论是精度还是效率,SVM都完败于当下随处可见的神经网络,关于从零开始实现SVM的意义,我也感到迷茫,但这一过程或多或少改变了我对机器学习的认知,一个简洁优雅的多项式时间精确算法,也许只能满足理论研究者的洁癖,而优化复杂模型的近似算法,在工程上赢得了未来。作为一门课程的大作业,笔者的实现难免存在疏漏和不足,希望读者谅解

题目描述

ACMOJ - 1125 - 合并优先队列

初始共有 $n$ 个数各成一组,设计一个算法支持以下操作:

  • 将第 $x$ 组和第 $y$ 组合并
  • 删除并输出第 $x$ 组中最小的数
  • 向第 $x$ 组中加入一个数 $y$

保证初始个数 $n\leq 3\times 10^5$,操作次数 $m\leq 3\times 10^5$

问题分析

我们构造 $n$ 个优先队列,直接模拟即可,对于合并操作,只要将第 $y$ 组中的元素依次弹出,插入到第 $x$ 组中即可。这种解法的复杂度高达 $O(nm\log n)$,但是由于测试数据的随机性,不会发生最坏情况

事实上,我们只需稍作修改,利用启发式合并的方法,每次合并将较小的队列合并到较大的队列中,这样可以保证每次合并的均摊复杂度为 $O(\log n)$,总复杂度为 $O\left ((n+m)\log ^2 n\right )$

一些细节

务必保证优先队列实现的正确性,且预分配空间不要过大,否则可能会内存超限

代码

这里只给出朴素算法直接模拟的代码,启发式合并读者可以自行实现

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
#include <cstdio>
#include <cctype>
#include <cstdarg>
#include <queue>
const int NMAX = 300005;

std::priority_queue<int, std::vector<int>, std::greater<int>> q[NMAX];
int n, m;

template <typename T>
void read(T &value)
{
T result = T(0);
bool sign = false;
char word = getchar();
while (!isdigit(word))
sign |= (word == '-'), word = getchar();
while (isdigit(word))
result = result * 10 + T(word - '0'), word = getchar();
value = sign ? -result : result;
}

template <typename T, typename... Ts>
void read(T &value, Ts &...remain)
{
read(value);
read(remain...);
}

int main()
{
read(n, m);
for (int i = 0, x; i < n; i++)
read(x), q[i].push(x);
for (int i = 0, x, a, b; i < m; i++)
{
read(x);
if (x == 0)
{
read(a, b);
while (!q[b].empty())
q[a].push(q[b].top()), q[b].pop();
}
else if (x == 1)
{
read(a);
if (q[a].empty())
printf("-1\n");
else
printf("%d\n", q[a].top()), q[a].pop();
}
else if (x == 2)
{
read(a, b);
q[a].push(b);
}
}
return 0;
}

题目描述

ACMOJ - 1049 - 前序中序求二叉树

给定二叉树的前序遍历和中序遍历,将二叉树以数组形式输出

保证节点个数 $n\leq 26$,且输出长度 $k\leq 10^3$

问题分析

这是一个经典的二叉树问题,我们使用递归的方法求解:

  • 选取前序遍历的第一个节点作为子树根节点
  • 在中序遍历中找到该节点的位置,其左侧构成左子树,右侧构成右子树
  • 将前序遍历相应地分成两棵子树,分别递归地重复上述过程

由于递归深度不超过 $n$,每层子问题的规模之和也不超过 $n$,因此时间复杂度为 $O(n^2)$

一些细节

  1. 注意数组开够大小,避免越界
  2. 注意递归的边界条件和参数传递准确无误

代码

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
#include <cstdio>
#include <cstring>
#include <algorithm>

char pre[35], mid[35], val[1005];
int len, cnt = 0;

void find(int pre_left, int pre_right, int mid_left, int mid_right, int order)
{
if (mid_left > mid_right)
return;
val[order] = pre[pre_left], cnt = std::max(cnt, order);
int root = mid_left;
while (root <= mid_right && mid[root] != pre[pre_left])
root++;
find(pre_left + 1, pre_left + root - mid_left, mid_left, root - 1, order << 1);
find(pre_left + root - mid_left + 1, pre_right, root + 1, mid_right, order << 1 | 1);
}

int main()
{
scanf("%s%s", pre, mid), len = strlen(pre);
find(0, len - 1, 0, len - 1, 1);
for (int i = 1; i <= cnt; i++)
if (val[i])
printf("%c ", val[i]);
else
printf("NULL ");
return 0;
}

题目描述

ACMOJ - 1216 - 括号匹配

模拟一个括号栈,包含()[]{}六种括号,支持如下四种操作:

  1. 向栈中压入元素
  2. 从栈中弹出元素
  3. 查询栈顶元素
  4. 判断栈中自底向上构成的括号序列是否匹配

保证操作次数 $n\leq 10^6$

问题分析

前置知识:利用栈进行括号匹配 参考链接

由于本题需要支持动态匹配,我们维护两个栈,$P$ 用于保存括号,$Q$ 用于括号匹配

  • 向 $P$ 中压入元素 $x$ 时,我们按照括号匹配的规则对 $Q$ 进行维护
  • 从 $P$ 中弹出元素 $x$ 时,我们将压入 $x$ 时对 $Q$ 的修改还原

由于压入 $x$ 时,可能会向 $Q$ 中压入元素,也可能会从 $Q$ 中弹出元素,因此我们需要记录 $x$ 对 $Q$ 的修改类型,方便后续的维护

由于每次操作的复杂度都是 $O(1)$,算法的复杂度为 $O(n)$,由于问题规模 $n\leq 10^6$,如果使用 $O(n^2)$ 的算法处理问题一定会超时

一些细节

  1. 推荐用数组模拟栈,代码会更加简洁
  2. 读入括号时,建议直接读入字符串,取其中首个元素,这样可以避免读入空字符的问题

代码

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
#include <cstdio>
const int NMAX = 1000005;

char s[NMAX], t[NMAX], b[5], c;
int n, x, p = 0, q = 0;
bool f[NMAX];

int main()
{
scanf("%d", &n);
for (int i = 1; i <= n; i++)
{
scanf("%d", &x);
if (x == 1)
{
scanf("%s", b), c = b[0];
s[++p] = c;
if (c == ')' && q && t[q] == '(')
q--, f[p] = true;
else if (c == ']' && q && t[q] == '[')
q--, f[p] = true;
else if (c == '}' && q && t[q] == '{')
q--, f[p] = true;
else
t[++q] = c, f[p] = false;
}
else if (x == 2)
{
if (!p)
continue;
c = s[p];
if (f[p])
{
if (c == ')')
t[++q] = '(';
else if (c == ']')
t[++q] = '[';
else if (c == '}')
t[++q] = '{';
}
else
q--;
p--;
}
else if (x == 3)
{
if (!p)
continue;
printf("%c\n", s[p]);
}
else if (x == 4)
{
printf("%s\n", q ? "NO" : "YES");
}
}
return 0;
}

暑假生活

小学期结束以后,是一段清闲的时光,而我正想趁此机会做些自己想做的事情。班里有同学邀请我参加ICPC,我参加了preseason training,在数场比赛后我清醒地意识到相比群里其他队爷,我的水平非常有限,遂犹豫是否返校参加选拔。我权衡再三,还是不愿舍弃在家躺平的暑假时光,学期中停课备赛也是我难以接受的。适逢老师邀请我进组打工,我便借机脱身了。后来那位同学如愿拿到了ICPC Regional的金牌,令人艳羡。回过头看,倘若当时我义无反顾地参加ICPC,或许也能取得不错的成绩,然而鉴于这一学期的灾难式的学习压力,放弃不失为一个明智的选择。

暑假末期,我提前十几天返校做迎新志愿者,以弥补网课学期可怜的素拓分数(事实上效果并不显著)。闲暇的时间正好做一些预习工作,赶在开学前刚好看完了大学物理,概率统计和信号与系统。此时的我满怀信心,对未来的学习进度仿佛胸有成竹,丝毫没有意识到形势之严峻。

线上与线下

经过上半年上海疫情的风波,大家都期待着回归正常的线下生活。然而现实总是离奇的,开学后上课的第一天就出现了感染者,线下教学未能撑满两节课就退回了线上。楼栋的封控一波接着一波,谁也不知道自己明天会不会吃上免费盒饭。长达六周的线上生活之后,终于回归了线下教学,但好景不长,一个月内疫情卷土重来,线上线下的同时进行使生活节奏愈发忙乱。又是两周之后,防疫口径发生重大转变,事实放开使疫情迅速在高校内蔓延,当我得知室友感染之后便火速逃离归家,尽管如此,生一场病已经难以避免。对于当下的人来说,这不过是一件必经的小事罢了。

一些成就感

学期的前几周,我奔忙于各种评优事项,填了不少测评和申请表,最终也如愿拿到了一些奖学金。虽然数额不算很大,但累积起来已经可以cover一年的生活费。电院各个专业名列前茅者中不乏致远工科同学的身影,也正是看到国家奖学金名单的时候,我意识到很多大佬一直隐藏在我身边。在学科营辅导程序设计和数据结构在我看来也是很有意义的工作,我结识了很多新的朋友,看到了来自不同学院的大佬在各自擅长的领域展现杰出的才能。这些都是成就感的来源,鼓励着我更加积极地学习和生活。成就纪念过往的荣光,而我面向未知和远方。

学习、学习、还是学习

如果说大一的课程学习是负重前行,那么大二上的学习必然是在泥沼中阴暗地爬行。同样是30+学分,这一学期的课程难度和任务压力远远超出我的预期,让我时常感到狼狈不堪。各种作业和实验纷至沓来,我不得不花费大量精力才能保质保量地完成它们。有些课程的作业时常需要倾注一整天或者数天的努力,然而在完成之前,另一项作业又发布了,于是任务越积越多,负担也愈发沉重。此外,有些课程本身难度较高,给学习带来了巨大的挑战,例如繁琐复杂的模电被压缩在2学时的课程中,课后却必须花费超过4学时的时间去重新理解。

在这个学期,不同课程的学习体验产生了显著的差异。以张宇昊和陶表帅两位老师的算法设计与分析为例,这门课程对于证明严谨性和英文水平的要求较高,在编程任务中也有严格的代码查重,但是由于课程重要性强,老师讲解水平较高,助教也都非常负责,大家普遍掌握程度较高,好评如潮。反观信号与系统等课程,上课讲解效率低下,对于课后作业缺乏良好的指导,导致大家难以掌握扎实,怨声载道。

纵观整个学期的学习过程,仿佛置身漫长的黑暗隧道,在望不到尽头的恐惧中匍匐前行,又被沉重的枷锁缚住手脚,长期不得挣脱。这种痛苦源于不合理的培养计划和课程设计,作为学生如蝼蚁般无力改变。我凭借着扎实的基础和坚定的毅力最终坚持下来,可那些普通而无助的人又该如何呢?

考试周

由于平时巨大的课程难度和任务量,即使临近考试周,大家手头仍有大量作业尚未完成,期末复习更是天方夜谭。得益于尽早完成任务的策略和前几周不懈的努力,我腾出了宝贵的一周提前进行复习。期间,我发现了大量的知识漏洞,并通过反复看书刷题来弥补,这一周的复习对部分课程的成绩至关重要。

总结来说,这一学期的考试有放水现象,平均分相较往年偏高。我对自己的成绩比较满意,其中大学物理和模拟电子技术的高分算是预料之外,计算机体系结构勉强90分不太满意,一方面是课程设计存在问题,不在本文讨论范围之内,另一方面在复习时忽视了对基本计算题型的训练。考虑到身体健康和时间安排,我申请了信号与系统的缓考,需要在寒假里做相应的复习和练习。

年终总结

我在自己2022年度总结的文案里写道:

过去的一年里,几乎所有的计划都被打乱了,当初信誓旦旦要做的事情,满怀期许想学的知识,都在生活的一团乱麻中抛之脑后。我曾不止一次地想在闲暇之余探索一些专业知识,亦或是出去逛逛,让自己的生活显得不那么无聊,然而现实是极度的忙碌和频繁的封控,以至于热情终于被消磨殆尽。不可否认,这一年对我的影响是极大的,它迫使我不断地观察和思考,潜移默化地重塑着我对世界的理解。闻媛老师的《经济与社会伦理》让我印象深刻,它让我深入地思考社会运作的合理性,重新审视那些看似理所应当的认知,建立稳定的伦理思维。

几个月来,我自囚于课程的一亩三分地难以脱身,为完成作业疲于奔命的狼狈真有几分抱薪救火的意味。我总是追求完满,却又太在意得失,囿于寻求局部的最优解,反而缺少了一以贯之的追求。我竭尽全力,究竟想要什么,这是一个亟待回答的问题。

新的开始

历史的进程呼啸而过,太多老人没有挺过那个寒冬,我坐在书桌前,早已听惯了白事的唢呐声,以至于有几分麻木。当春寒料峭时,我重新踏进校门,已是另一番图景,宽阔的道路上再没有人掩面而行,疾病像是从这片土地上消失了一般,只剩下宁静和安详。我隐隐感觉到一切都要归于寻常了,但21年入学的我其实并不知道何谓寻常,反而多了一些不安。开学的第一节课是俞凯老师的《智能语音识别》,上课铃响时教室里几乎是座无虚席,给人一种久违的紧迫感,把网课时期的慵懒和懈怠一扫而空。这是新学期的伊始,也是大学阶段一个全新的开始。

清闲的时光

学期的前几周,是在清闲中度过的,没有太多数学和物理课程的作业压力,也没有太高的课程难度,每天都有大把的时间可以自由支配。在这段时间里,我时常在傍晚出门散步,或是在周末和室友去校外改善伙食,潮汕汤面的海鲜汤面,御陕坊的油泼扯面,还有海宁煲和东记水饺,都称得上物美价廉。我开始读手边的CSAPP,并惊叹于它的包罗万象,从《程序设计》《计算机体系结构》到《操作系统》《计算机网络》都有深入浅出的讲解,无愧于作为一本顶级的自学指南。与此同时,我的CET6意外地拿到了600多分,这必须归功于试点班的培养模式,倘若没有江波老师全英文授课的《线性优化与凸优化》,我的听力大概会维持在高中毕业时的水准。舒适的生活节奏令人沉湎,但我清楚地知道这是短暂的,一旦各种作业布置下来,就会回归写代码赶报告的状态。

谈谈大作业

临近期末的压力,80%来源于各门课程的大作业,剩下20%是小作业。

各种大作业的质量参差不齐,有些难度适中,与课程内容结合紧密,能够很好地提高学生的课程理解,有些过于简单,陈陈相因,是卷报告页数的高发地,有些难度过大,不知所云,与课程内容严重脱节,和造火箭并无二致。《深度学习》的作业设计便是一个表率,从回归到分类,从CNN到RNN,最终实现一个拼图任务,由浅入深,真正体现了张量变换的艺术。相比之下,《机器学习》和《智能语音识别》的大作业可谓折磨,前者是因为任务设计缺乏深思熟虑,低估了完整复现传统算法的难度,后者则是缺乏详尽的指导文档,对基础薄弱者极度不友好。对习惯于赶deadline的同学而言,学期的最后几周无疑是噩梦,甚至还要在考试周抽出时间在超算上排队完成LVCSR实验。然而,大作业扎堆的问题并没有给我造成困扰,我坚持任务提前的策略,提前一周完成了所有任务,于是我能够产出质量更高的内容,甚至有极其充裕的时间复习备考。

回顾这一学期的各门课程,我不得不承认多数课程的质量相当高,诸如《数字信号与图像处理》《随机过程》都能让人感觉到作业设计的用心之处,但并非所有老师都愿意在教学上投入大量精力,在现行的评价体系下这也无可厚非。

又逢期末

“随机过程随机过,量子力学量力学”,尽管已是身经百战,期末考试的压迫感依旧十足。好在时间充裕,我便把王先智老师的PPT完整地看了一遍,再做试卷时倒也不是那么困难了,大概是老师们情知在工科开设《量子力学》有些为难学生,有意宽松给分,互相给一个台阶下罢了。《数字信号与图像处理》和《随机过程》的难度相比往年显著提升,这当然在预料之内,但由于老师善于捞人,给分并没有变差,唯有《机器学习》由于繁重的任务和苛刻的给分饱受抨击。

更有意思的是3学分的毛概被推上风口浪尖,由于不同教学班的均分存在显著差异,竟有同学致信教务处严查给分高的老师。诚然,利己主义者随处可见,但这种魔怔于学积分的心态着实令人感到可悲,有些人或许时至今日还没能明白“选择大于努力”这个浅显的道理。写到这里我不免要赞美北老师,他让我第一次发现红课也可以如此生动充实,而非令人昏昏欲睡的,我们的高等教育正是需要这类老师,把学生的素质培养和人格健全作为己任。

总而言之,这个期末还算令人满意,毕竟大多数课程都拿到了满绩,这应当归功于老师和我的共同努力。

写在最后

日子就这样一天天地过去,只有在看见新生军训的时候,我才会想起自己要成为大三老狗了。这一年中,我接触了很多新知,认识了很多大佬,实验室的实习也逐渐走上正轨。更多的选择摆在了我的面前,迫使我反复权衡,以免走错任何一步。无数令人不安的消息萦绕在耳畔,遮住了前路的微光,这个世界会好吗?我并不知道答案,我只能在黑暗中提灯探行,企图找到那一条属于我自己的幽径。

题目描述

ACMOJ - 1486 - 布尔表达式求值

给定合法的布尔表达式,规则如下:

  • $\text{f}$:字面量 false
  • $\text{t}$: 字面量 true
  • $\text{!}(x)$:非运算 not
  • $\text{&}(x_1,x_2,\dots )$:与运算 and
  • $\text{|}(x_1,x_2,\dots )$:或运算 or

计算布尔表达式的结果,保证长度 $|S|\leq10^4$

问题分析

与后缀表达式的处理方式类似,我们采用栈来存储表达式内容,由于括号和逗号并不产生实际语义,我们只保存操作符和字面量

顺序遍历表达式,将操作符和字面量依次入栈,忽略左括号和逗号,每当遇到右括号时执行出栈并完成一组计算,具体细节如下:

  • 不断弹出栈顶,直到遇到操作符
  • 记录弹出字面量的类型,即是否出现过 true 和 false
  • 对于 not 运算,只需取反唯一的运算数
  • 对于 and 运算,若出现过 false,结果为 false,否则为 true
  • 对于 or 运算,若出现过 true 结果为 true,否则为 false

最后,我们将本次计算所得的结果以字面量的形式放回栈顶

该算法可在 $O(n)$ 的时间复杂度内完成表达式的计算

代码

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
#include <cstdio>
#include <cstring>
const int SIZE = 20005;

template <typename ElementType>
class Stack
{
public:
virtual ~Stack() {}
virtual bool empty() const = 0;
virtual void push(const ElementType &element) = 0;
virtual ElementType pop() = 0;
virtual ElementType top() const = 0;
virtual void clear() = 0;
};

template <typename ElementType>
class LinkedStack : public Stack<ElementType>
{
private:
struct StackNode
{
ElementType data;
StackNode *next;
StackNode() : next(nullptr) {}
StackNode(const ElementType &_data, StackNode *_next = nullptr) : data(_data), next(_next) {}
~StackNode() {}
};
StackNode *head;

public:
LinkedStack();
~LinkedStack();
bool empty() const;
void push(const ElementType &element);
ElementType pop();
ElementType top() const;
void clear();
};

template <typename ElementType>
LinkedStack<ElementType>::LinkedStack()
{
head = nullptr;
}

template <typename ElementType>
LinkedStack<ElementType>::~LinkedStack()
{
clear();
}

template <typename ElementType>
bool LinkedStack<ElementType>::empty() const
{
return head == nullptr;
}

template <typename ElementType>
void LinkedStack<ElementType>::push(const ElementType &element)
{
head = new StackNode(element, head);
}

template <typename ElementType>
ElementType LinkedStack<ElementType>::pop()
{
if (head == nullptr)
throw "Stack is already empty!";
StackNode *temp = head;
ElementType value = temp->data;
head = head->next;
delete temp;
return value;
}

template <typename ElementType>
ElementType LinkedStack<ElementType>::top() const
{
if (head == nullptr)
throw "Stack is already empty!";
return head->data;
}

template <typename ElementType>
void LinkedStack<ElementType>::clear()
{
StackNode *temp;
while (head != nullptr)
{
temp = head;
head = head->next;
delete temp;
}
}

LinkedStack<char> waiting;
char expression[SIZE];
int length, count = 0;

int main()
{
scanf("%s", expression);
length = strlen(expression);
for (int position = 0; position < length; position++)
{
if (expression[position] == '(' || expression[position] == ',')
continue;
else if (expression[position] == ')')
{
bool right = 0, wrong = 0;
while (!waiting.empty() && (waiting.top() == 't' || waiting.top() == 'f'))
{
if (waiting.pop() == 't')
right = 1;
else
wrong = 1;
}
char operation = waiting.pop();
if (operation == '!')
{
if (right)
waiting.push('f');
else
waiting.push('t');
}
else if (operation == '&')
{
if (wrong)
waiting.push('f');
else
waiting.push('t');
}
else if (operation == '|')
{
if (right)
waiting.push('t');
else
waiting.push('f');
}
}
else
waiting.push(expression[position]);
}
printf("%d\n", waiting.top() == 't' ? 1 : 0);
return 0;
}

题目描述

ACMOJ - 14373 - 循环队列

实现一个循环队列,支持如下操作:

  1. 将新的元素入队,并输出当前队尾的下标和元素个数
  2. 将队首元素出队,并输出当前队首的下标和元素个数

问题分析

按照题目要求实现即可,本题输出较为严格,请注意实现的规范性

代码

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
#include <iostream>

template <typename ElementType>
class Queue
{
public:
virtual ~Queue() {}
virtual bool empty() const = 0;
virtual void push(const ElementType &element) = 0;
virtual ElementType pop() = 0;
virtual ElementType front() const = 0;
virtual void clear() = 0;
};

template <typename ElementType>
class SequentialQueue : public Queue<ElementType>
{
private:
ElementType *elementData;
int headPosition, tailPosition, totalCapacity;
void expand();

public:
SequentialQueue(int size = 10);
~SequentialQueue();
bool empty() const;
void push(const ElementType &element);
ElementType pop();
ElementType front() const;
void clear();
int get_head_position();
int get_tail_position();
int get_queue_length();
};

template <typename ElementType>
void SequentialQueue<ElementType>::expand()
{
ElementType *temp = elementData;
elementData = new ElementType[2 * totalCapacity];
for (int i = 1; i < totalCapacity; i++)
elementData[i] = temp[(headPosition + i) % totalCapacity];
headPosition = 0;
tailPosition = totalCapacity - 1;
totalCapacity *= 2;
delete[] temp;
}

template <typename ElementType>
SequentialQueue<ElementType>::SequentialQueue(int size)
{
elementData = new ElementType[size];
totalCapacity = size;
headPosition = tailPosition = 0;
}

template <typename ElementType>
SequentialQueue<ElementType>::~SequentialQueue()
{
delete[] elementData;
}

template <typename ElementType>
bool SequentialQueue<ElementType>::empty() const
{
return headPosition == tailPosition;
}

template <typename ElementType>
void SequentialQueue<ElementType>::push(const ElementType &element)
{
if ((tailPosition + 1) % totalCapacity == headPosition)
expand();
tailPosition = (tailPosition + 1) % totalCapacity;
elementData[tailPosition] = element;
}

template <typename ElementType>
ElementType SequentialQueue<ElementType>::pop()
{
if (headPosition == tailPosition)
throw "Queue is already empty!";
headPosition = (headPosition + 1) % totalCapacity;
return elementData[headPosition];
}

template <typename ElementType>
ElementType SequentialQueue<ElementType>::front() const
{
if (headPosition == tailPosition)
throw "Queue is already empty!";
return elementData[(headPosition + 1) % totalCapacity];
}

template <typename ElementType>
void SequentialQueue<ElementType>::clear()
{
headPosition = tailPosition = 0;
}

template <typename ElementType>
int SequentialQueue<ElementType>::get_head_position()
{
return headPosition;
}

template <typename ElementType>
int SequentialQueue<ElementType>::get_tail_position()
{
return tailPosition;
}

template <typename ElementType>
int SequentialQueue<ElementType>::get_queue_length()
{
return (tailPosition - headPosition + totalCapacity) % totalCapacity;
}

template <typename T>
void read(T &x)
{
x = 0;
char c = getchar();
while (c < '0' || c > '9')
c = getchar();
while ('0' <= c && c <= '9')
{
x = x * 10 + c - '0';
c = getchar();
}
}

int main()
{
int s, n, t, x;
read(s), read(n);
SequentialQueue<int> Q(s);
while (n--)
{
read(t);
if (t == 0)
{
read(x);
Q.push(x);
std::cout << Q.get_tail_position() << " " << Q.get_queue_length() << std::endl;
}
else if (t == 1)
{
if (!Q.empty())
Q.pop();
std::cout << Q.get_head_position() << " " << Q.get_queue_length() << std::endl;
}
}
return 0;
}