这两天对我来说算是一个小型的知识冲刺。周六周日加起来大约14个小时,集中啃了四块东西:python文件读取、编程英语、机器学习里的神经网络、算法导论里的分治法。平时工作里也会用到文件读写和pandas,但对底层细节一直模模糊糊;机器学习只看过概念没动过手;算法停留在刷题层面,推导不深。这次干脆把每一个主题都按“概念-代码-踩坑-换角度再看”的方式过了一遍,感觉比单纯看视频效率高很多。这篇文章就当作一份可复用的学习总结,把我两天的笔记、示例代码和翻车记录都整理出来。如果你也是自学路线,这份内容应该能直接抄作业,不需要再自己到处凑资料。
1. Python文件读取:从内置open到pandas,这两天踩平的读写坑
文件读取是Python最基础也最容易出问题的环节。平时脚本里写open("xxx.txt")挺顺手,但一旦涉及路径、编码、大文件,各种坑就冒出来了。我这次系统地把内置文件操作和pandas的常见场景都过了一遍,整理成下面的笔记。
1.1 先搞清楚文件路径:相对路径和绝对路径的实战选择
大多数教程只告诉你open("data.txt"),但实际项目里这个写法很容易翻车。因为Python的工作目录不是你写代码的文件所在目录,而是你执行命令时所在的目录。比如你在/home/user/project下执行python /home/user/project/scripts/read.py,那open("data.txt")查找的是/home/user/project/data.txt,不是scripts/data.txt。
所以我现在写脚本,第一件事就是打印当前工作目录:
import os print(os.getcwd())确认当前工作目录之后再决定用相对路径还是绝对路径。如果只是自己用,绝对路径最省心,但要考虑脚本在不同机器上运行时路径写死的问题。我自己的习惯是:脚本放在项目根目录下,数据文件放在data/子目录,然后用Path对象拼接路径:
from pathlib import Path base_dir = Path(__file__).resolve().parent data_file = base_dir / "data" / "example.txt"Path对象比字符串os.path.join优雅很多,支持/运算符直接拼接,跨Windows、Linux、macOS都没有反斜杠转义的问题。Windows用户尤其容易踩反斜杠的坑,比如"C:\Users\name\data.txt"里的\n会被当成换行符,虽然在文件路径里多数情况不报错,但一旦目录名是\t、\n这种超恶心。用Path就完全避开这个问题。
1.2 open/with/编码:基础API不要只停留在写demo
open函数最需要注意的是模式参数。默认是"r"文本读模式,如果读二进制文件比如图片、音频,必须用"rb";写二进制用"wb"。还有个容易被忽略的encoding参数:open("file.txt", "r", encoding="utf-8")。不指定编码时,Windows下默认可能是gbk,Linux下是utf-8,同一份代码跨平台跑可能直接乱码。
我这次专门测试了read、readline、readlines三兄弟,它们的区别很简单:
read()一次性把整个文件内容读成字符串,适合小文件。readline()每次读一行,适合逐步处理,比如日志文件。readlines()返回每行组成的列表,注意它会保留换行符。
实际开发中最常用的是直接迭代文件对象,内存占用小:
with open("data.txt", "r", encoding="utf-8") as f: for line in f: print(repr(line))打印用repr是因为每行结尾会带\n,直接print看不到换行的存在。如果需要去掉换行,用line.strip()或line.rstrip("\n")。这里有个小坑:strip()会把行首行尾的空格和\t也去掉,如果只在末尾去换行,最好用rstrip("\n")。
为什么一定要用with?因为文件打开后如果不关闭,进程会一直占着文件句柄。with是上下文管理器,代码块结束后自动调用close(),即使中间抛异常也会关闭。我自己早期写代码经常忘了close,后来形成肌肉记忆:只要用文件,一律with。
1.3 pandas读取Excel/CSV:真正的效率工具
如果是处理表格数据,直接读成DataFrame比手动逐行解析快一个数量级。这两天实测最常用的两个函数:
import pandas as pd df_csv = pd.read_csv("data.csv", encoding="utf-8") df_excel = pd.read_excel("data.xlsx", sheet_name="Sheet1", engine="openpyxl")read_csv需要关注几个参数:sep指定分隔符,默认是逗号,但有些数据是制表符或分号;header指定表头在哪一行,header=None表示无表头;skiprows跳过开头几行。我这次处理一个从系统导出的csv,第一行是生成时间说明,第二行才是真正的列名,一开始直接读进去,列名全是乱的,后来加了skiprows=1才正常。
read_excel要特别注意engine参数。.xlsx文件需要openpyxl,.xls文件需要xlrd,没装对应库会直接报ImportError。另外sheet_name=None可以一次读取所有Sheet,返回一个字典,键是Sheet名,值是对应DataFrame。这个在处理多Sheet报表时非常实用。
读取之后的乱码问题大概率是编码不对。我建议拿到外部文件先做个小测试:
with open("unknown.csv", "rb") as f: print(f.read(50))打印前50字节,根据内容判断是utf-8还是gbk。如果看到\xff\xfe开头,就是UTF-16,Python里要指定encoding="utf-16"。别问我为什么知道,问就是吃过亏。
1.4 异常处理和踩坑笔记
文件操作最常见的异常就三类:FileNotFoundError、PermissionError、UnicodeDecodeError。不处理异常时,程序直接崩溃,而且报错信息对新手不友好。我现在统一用try/except包住关键文件读取:
try: with open("data.txt", "r", encoding="utf-8") as f: content = f.read() except FileNotFoundError: print("文件不存在,先检查路径") except PermissionError: print("没有权限读取,检查文件权限") except UnicodeDecodeError: print("编码不对,尝试 gbk 或 utf-8")这三天我还遇到一个奇怪问题:连续往同一个CSV文件追加写入,数据中间出现了空行。原因是用"w"模式写入时,如果newline不处理,Windows下写入的换行符会多一个\r。解决办法是打开文件时加newline="":
with open("output.csv", "w", newline="", encoding="utf-8") as f: f.write("a,b,c\n")这个细节在文档里写得清楚,但很多人不会注意。总之,文件读取的边界情况比想象中的多,建议每天抽半小时专门处理一种异常,积累一段时间就很有手感。
2. 编程英语:不是背单词,是在报错信息里学编程
这个主题是我这两天加进去的计划。以前看英文文档总是硬着头皮,遇到报错就截图翻译,效率很低。后来发现英语和编程的联系比想象中紧密得多,很多概念单词本身就是操作或逻辑的描述,理解词根之后看代码、看文档都不需要“翻译”这个过程。
2.1 为什么自学编程要专门学编程英语
报错信息、官方文档、开源库的README、Stack Overflow上的回答,绝大部分都是英文。即使有中文教程,很多专业术语也保留了英文原词:比如epoch、batch、gradient,翻译成中文反而别扭。不懂这些词,连查资料都不知道搜什么关键词。
我这两天的经验是:不必专门背单词,但每遇到一个反复出现的词,就停下来查清楚它的动词、名词含义和语境。比如parse翻译是“解析”,但真正含义是把字符串拆成结构化数据,理解了这点,看到json.load()是“把JSON文本解析为Python对象”就一目了然。
2.2 高频词汇分类整理
我按日常开发的频率整理了几个类别,都是这两天真实用到的:
| 场景 | 词汇 | 含义 |
|---|---|---|
| 文件操作 | read, write, append, close | 读、写、追加、关闭 |
| 文件操作 | path, directory, file, extension | 路径、目录、文件、扩展名 |
| 数据结构 | list, tuple, dictionary, set | 列表、元组、字典、集合 |
| 数据结构 | push, pop, insert, delete, append | 压入、弹出、插入、删除、追加 |
| 流程控制 | iterate, loop, condition, branch | 遍历、循环、条件、分支 |
| 函数 | function, argument, parameter, return | 函数、实参、形参、返回 |
| 错误 | exception, traceback, syntax, runtime | 异常、回溯、语法、运行期 |
| 机器学习 | feature, label, sample, normalize | 特征、标签、样本、归一化 |
| 机器学习 | forward pass, backward pass, gradient | 前向传播、反向传播、梯度 |
| 算法 | divide, conquer, combine, recurrence | 分解、解决、合并、递归 |
这些词不需要刻意背,写代码时碰到一次记一次,比单词书有效得多。我是在笔记本上开了一页专记编程英语,只写单词+一个代码示例,不写中文翻译,因为代码里中文注释多了容易混淆思路。
2.3 在报错信息中养成阅读英文的习惯
报错信息是学习编程英语最好的语料。举一个实际的例子,这是我调试神经网络时最常见的报错:
Traceback (most recent call last): File "train.py", line 20, in <module> loss.backward() NameError: name 'loss' is not defined第一行Traceback是“回溯”,指错误发生时的调用路径。然后每一行都说明文件名、行号、模块名,最后一行NameError:name 'loss' is not defined才是核心问题——loss这个名字没定义,也就是这个变量还没赋值或拼写错误。
很多新手看到Traceback就慌,其实只要读最后一行和往上数几行,定位很快。我现在遇到报错,第一反应不是翻译成中文,而是直接在脑子里过一遍:IndexError是索引越界,KeyError是字典/DataFrame里没有这个键,AttributeError是对象没有这个属性。用英文读秒懂,用中文反而要绕一圈。
2.4 通过英文术语反推概念本质
有些英文术语甚至能帮你理解概念本身。比如divide and conquer(分治法),conquer是“征服/解决”,在算法里是指把子问题解决掉,而不是“征服数据”,这个动词理解到位了,递归结构就自然了。backpropagation就是backward propagation的缩写,意思是误差从后往前传播,理解了“back”和“forward”的相对关系,神经网络的前向/反向过程就清晰了。recurrence和recur同根,表示“再次发生”,递归式就是一步一步往回套的递推关系。
所以编程英语不是独立于编程之外的技能,它嵌在每一行代码、每一个报错、每一份文档里。只要坚持“遇到一个词查清楚一个”,三个月之后读英文文档的流畅度会明显不一样。
3. 神经网络:从感知机到反向传播,把概念变成能跑的代码
机器学习涵盖太广,我这次特意聚焦神经网络,原因很简单:它既有直观的图形化理解(神经元、层、激活),又有严格的数学推导(链式法则、梯度),非常适合用来打通“理论”和“代码”之间的障碍。
3.1 神经网络的构成:从感知机到全连接层
先从最基础的结构说起。一个神经元接收多个输入,每个输入都有一个权重,神经元内部把输入加权求和,再加上一个偏置,最后通过激活函数输出结果。如果用数学表达,就是:
z = W * x + b a = activation(z)其中W是权重向量,x是输入向量,b是偏置标量,activation是激活函数。多个神经元横向排布就是一层,层和层之间的输出-输入连接就是全连接层。一个最简单的两层神经网络就是:
- 输入层:接收原始数据,比如一张28x28像素的图片,展平后是784维。
- 隐藏层:若干神经元,对输入做非线性变换。
- 输出层:给出最终的预测结果,比如手写数字分类的10个类别概率。
没有激活函数的神经网络无论堆多少层都只是线性变换,本质上是一个线性回归。激活函数的作用是引入非线性。我实践的三个主要激活函数:
| 函数 | 公式 | 特点 |
|---|---|---|
| Sigmoid | 1/(1+e^(-x)) | 输出在0到1之间,适合二分类输出;但两端梯度饱和 |
| Tanh | (e^x-e^(-x))/(e^x+e^(-x)) | 输出在-1到1之间,均值更接近0 |
| ReLU | max(0, x) | 计算简单,梯度不饱和,但负区间梯度为0 |
ReLU在隐藏层中基本是默认选择。一开始我纠结为什么负区间梯度为0了还能用,后来理解了反向传播是在损失函数可导的前提下做链式求导,负区间梯度为0只会让部分神经元“死亡”,但对整体来说稀疏性反而有助于泛化。
3.2 前向传播、损失、反向传播:一条链式法则串起来
前向传播很好理解:输入数据经过每一层计算,得到输出。然后计算损失函数,衡量预测值和真实值的差距。分类问题常用交叉熵损失,回归问题用均方误差。反向传播则是利用微积分里的链式法则,从输出层开始逐层计算每个参数对损失的梯度,然后用梯度下降更新参数。
我这次用手推了一个最简单两层网络的一轮反向传播,核心逻辑是这样的:
设输出层损失对输出层输出的梯度为delta_out,它等于损失函数对输出层激活值的偏导乘上激活函数的导数。隐藏层的误差delta_hidden等于delta_out乘上输出层权重,再乘上隐藏层激活函数的导数。最后每个参数的更新量就是该层的误差乘上上一层输出。
用numpy实现的核心部分:
import numpy as np def sigmoid(x): return 1 / (1 + np.exp(-x)) def sigmoid_deriv(a): return a * (1 - a) # 假设 W1, b1, W2, b2 已初始化,X 是输入,Y 是真实值 for epoch in range(200): # 前向传播 hidden = sigmoid(np.dot(X, W1) + b1) output = sigmoid(np.dot(hidden, W2) + b2) # 均方误差损失 loss = np.mean((Y - output) ** 2) # 反向传播 d_output = 2 * (output - Y) * sigmoid_deriv(output) d_hidden = np.dot(d_output, W2.T) * sigmoid_deriv(hidden) # 参数更新 W2 -= learning_rate * np.dot(hidden.T, d_output) b2 -= learning_rate * np.sum(d_output, axis=0) W1 -= learning_rate * np.dot(X.T, d_hidden) b1 -= learning_rate * np.sum(d_hidden, axis=0)这段代码去掉了很多边角细节,比如初始化、归一化、轮次打印,但反向传播的核心计算就这几行。我发现只要自己推一遍链式法则,再看torch或TensorFlow里的loss.backward()就不会觉得是黑魔法。
3.3 学习率、batch、epoch:三个最容易被忽略的超参数
理论都通了,代码能跑起来,模型效果却可能很差。这两天我调参踩的坑都集中在三个超参数上:
learning_rate(学习率):每次参数更新的步长。初始设成0.1,损失曲线直接震荡,后来改成0.01才收敛。学习率过大会越过最优点,过小则收敛极慢。实践中常用0.01或0.001起步,配合学习率衰减。batch(批次大小):每次更新参数用的样本数。最极端的是batch=1,即随机梯度下降,噪声很大;batch等于全量数据就是批量梯度下降,稳定但计算量大。常用32、64、128。batch越小,梯度噪声越大,反而有时能跳出局部最优。epoch(轮次):完整遍历整个训练集的次数。不是越大越好,后期过拟合风险增加。我习惯在每个epoch结束时在验证集上算一下loss,一旦验证loss开始升高,就提前停止。
除此之外,输入数据的归一化非常重要。我一开始直接把0到255的像素值喂给神经网络,loss死活降不下去;归一化到0到1之后,一轮的效果就比之前十轮还好。原因是神经网络对输入尺度很敏感,大数值会让权重梯度范围变得很大,训练不稳定。
3.4 用numpy手搓一个两层网络,理解比调包更重要
这节想强调一点:如果你正在学神经网络,不要一上来就用PyTorch或TensorFlow,先用numpy手写一个最简版本。因为框架帮我们做了太多事:自动求导、反向传播、参数优化,全被封装成了黑盒。手写一遍之后,每个环节的输入输出维度、矩阵乘法形状、梯度计算公式,都会非常清晰。
比如最常见的报错ValueError: shapes (64,10) and (16,64) not aligned,看起来莫名其妙。但你自己写一遍就会发现,矩阵乘法要求前一个矩阵的列数等于后一个矩阵的行数,网络层与层之间的维度必须匹配。一旦理解了维度流动,这类报错一眼就看出是哪个W的形状写错了。
纸上推一遍加上手写一次,十个错误有八个都能自己定位。神经网络没那么神秘,本质上就是一堆矩阵乘法和微积分。
4. 分治法:归并排序、最大子数组与递归式推导
算法导论的分治法是我这两天的最后一盘菜。以前刷题时经常遇到merge_sort,但只知道是递归,没认真理解过“分治”这个思想。这次从头啃了一遍,很多东西都串起来了。
4.1 分治三步骤:分解、解决、合并
分治法的核心思想是三步:分解、解决、合并。
- 分解(Divide):把原问题划分成若干个规模更小的子问题。
- 解决(Conquer):递归地解决每个子问题。如果子问题小到可以直接解决,就不再递归。
- 合并(Combine):把子问题的解合并成原问题的解。
最经典的例子就是归并排序:把数组从中间拆成两半,递归排序两半,然后再合并两个有序数组。这个思想之所以能成立,前提是子问题与原问题结构相同、规模更小,最终小到可以直接解决(比如数组长度为1时天然有序)。
理解这三点之后,看很多算法就顺了:快速排序是选一个基准值拆分,然后拼接;二分查找是每次砍掉一半,直接在子区间里继续;二叉树的遍历也是分治思想,先处理左子树,再处理右子树。我最大的体会是:分治不是一种具体算法,而是一种思考方式。
4.2 归并排序:学习分治的入门案例
归并排序的代码我在面试和日常工作里写过很多次,但这次才认真分析了它的过程。先看核心实现:
def merge_sort(nums): if len(nums) <= 1: return nums mid = len(nums) // 2 left = merge_sort(nums[:mid]) right = merge_sort(nums[mid:]) return merge(left, right) def merge(a, b): i = j = 0 result = [] while i < len(a) and j < len(b): if a[i] <= b[j]: result.append(a[i]) i += 1 else: result.append(b[j]) j += 1 result.extend(a[i:]) result.extend(b[j:]) return result理解这个代码的关键在于递归的“信任”:不需要跟踪每一层递归到底怎么走,只需要相信merge_sort(left)能返回一个排好序的数组,merge_sort(right)也一样。然后是merge函数,它利用左右两个数组各自有序的特点,用双指针线性扫描合并,时间复杂度O(n)。
这里有个细节我之前一直忽略:nums[:mid]每次创建新列表,空间复杂度是O(n)。如果追求更优,可以用原地索引区间的方式,只传递数组和start、end下标,避免切片复制。实际工程中归并排序主要用于外部排序,比如内存放不下整个文件时,先分段排序再归并,完全不依赖随机访问。
4.3 最大子数组问题:跨越中点的思维转变
归并排序之后,我又选了最大子数组问题练手。问题描述是:给定一个整数数组,找出一个连续子数组,使得子数组的和最大。比如[-2, 1, -3, 4, -1, 2, 1, -5, 4]的最大子数组是[4, -1, 2, 1],和为6。
暴力解是枚举所有起点和终点,复杂度O(n²)。用分治法可以优化到O(n log n),而且思路很漂亮:把数组分成两半,最大子数组要么完全在左边,要么完全在右边,要么跨越中点。前两种情况递归解决即可,关键在第三种情况:从中间向左扫描找最大连续和,从中间向右扫描找最大连续和,两者之和就是跨越中点的最大和。
这个“跨越中点”的分析是重点:最大子数组不会说破就必须和哪个边界对齐,它可能横跨分界点。所以分治的合并步骤不能简单返回max(左,右),还必须考虑跨过中点的可能性。
我写这段代码时踩了一个逻辑坑:从中间向左扫描时,是在一个部分和的过程中找最大,而不是从端点直接开始累计。正确的做法是维护left_sum并不断更新最大值,扫描结束后得到左边包含中点元素的最大和。同理右边。最后跨中值就是left_max + right_max。
4.4 递归式和主定理:分析时间复杂度的工具
分治法的时间复杂度通常用递归式来表示。以归并排序为例,T(n)=2T(n/2)+O(n),意思是规模为n的问题被分解为2个规模为n/2的子问题,合并需要O(n)。解这个递归式的标准工具是主定理。
主定理针对形如T(n)=aT(n/b)+f(n)的递归式。比较n^(log_b a)和f(n)哪个增长更快:
- 情况1:如果
f(n)增长慢于n^(log_b a),则T(n)=Θ(n^(log_b a))。 - 情况2:如果两者增长速度相当,则T(n)=Θ(n^(log_b a) log n)。
- 情况3:如果
f(n)增长快于n^(log_b a),且满足某种正则条件,则T(n)=Θ(f(n))。
以归并排序为例:a=2,b=2,f(n)=O(n),n^(log_b a)=n^1=n,f(n)和n^(log_b a)同阶,属于情况2,所以T(n)=Θ(n log n)。这几个公式看起来抽象,但套进去算两次就记住了。
我这次还花时间分析了二分查找的递归式:T(n)=T(n/2)+O(1),这里a=1,b=2,n^(log_2 1)=n^0=1,f(n)=O(1),两者同阶,情况2,结果是Θ(log n)。
4.5 分治法的边界条件:递归设计最容易翻车的地方
分治法看着简洁,实际写的第一个版本几乎都会在边界条件上翻车。最常见的问题:
- 递归终止条件写错。比如归并排序里
len(nums) <= 1,如果不加<=只写==,当传入空数组时就会无限递归。 - 左右区间划分重叠或遗漏。比如
mid = len(nums) // 2,左半部分是nums[:mid],右半部分是nums[mid:],两边不重叠也不遗漏。如果错写成nums[mid+1:],元素就会漏掉。 - 跨越中点的范围问题。求最大子数组跨越中点时,左边扫描的起点是
mid,右边扫描的终点是mid+1,两个子数组必须紧挨着,否则不能拼成一个连续数组。
这些边界坑没有捷径,只能靠多画几个例子和单步调试。我花了大约四十分钟,把数组长度从1到8的归并排序过程都手写了一遍,才彻底理解切片长度的计算逻辑。
另一个体会是:分析分治法的时间复杂度不能只看递归式,还要看合并步骤。有些问题分解很快,但合并时不小心写成了O(n²),整体复杂度就上去了。写完代码之后,自己算一遍merge里的循环次数和递归层数,能发现不少隐藏的性能问题。
两天时间不长,但把这四个主题串在一起之后,我突然觉得机器学习和算法并没有想象中那么“高大上”。神经网络的前向传播和反向传播就是链式法则和矩阵乘法,分治法就是分解、解决、合并,文件读取就是路径、编码和模式,编程英语就是多读报错、多查词根。都是很具体的事。最后分享一个我自己验证过的小习惯:学完一个主题的第二天早上,不翻笔记,直接重写一遍昨天的核心代码。写不出来就回去再看,写出来才是真的会了。这套流程帮我过滤掉很多“看过就会”的假象,也让我对接下来的深度学习案例有了更踏实的起点。