百味皆苦 java后端开发攻城狮

AI-发展历程

2025-12-20
百味皆苦
AI

✅ 系统梳理(由浅入深,逐步展开):

模块 内容概述
1. 人工智能发展史 从图灵测试到ChatGPT,AI是如何一步步走到今天的?
2. 机器学习与深度学习基础 不只是概念,带你从0实现一个线性回归+神经网络
3. 自然语言处理(NLP) 从词袋模型到Transformer,理解GPT是如何“说话”的
4. 大模型与预训练+微调机制 从GPT-1到GPT-4,模型是怎么“长大”的?
5. 智能音箱技术解析(以小爱为例) 语音唤醒、ASR、NLP、TTS、技能系统全链路演示
6. 多模态模型与Agent智能体 看图说话、听声辨人,AI如何“多感官”协作?
7. RAG技术+查询改写+提示词工程 大模型如何“查资料”回答问题?
8. 知识图谱与向量化模型 从结构化知识到语义向量,AI如何“理解”世界?
9. 语义相似度计算与MCP技术 文本如何比对?多模态协同处理如何实现?

🧠 第一章:人工智能概念的提出与发展历程

1.1 人工智能的诞生:从“机器能否思考”到“人工智能”一词的提出

📌 1950年:图灵测试(Turing Test)

  • 提出者:艾伦·图灵(Alan Turing)

  • 核心问题“机器能否思考?”

  • 图灵测试定义

    如果一台机器能通过文字对话,让人类无法分辨它是机器还是人,那么它就具备了“智能”。

通俗理解

就像你在网上聊天,如果对方是机器人,但你完全看不出来,那它就“通过”了图灵测试。

📌 1956年:达特茅斯会议(Dartmouth Workshop)

  • 地点:美国达特茅斯学院

  • 参与者:约翰·麦卡锡(John McCarthy)、马文·明斯基(Marvin Minsky)、克劳德·香农(Claude Shannon)等

  • 成果首次提出“人工智能(Artificial Intelligence, AI)”这一术语

  • 目标

    “让机器模拟人类智能的各个方面,包括学习、推理、规划、语言理解等。”

历史意义

这次会议标志着人工智能作为一门独立学科正式诞生


1.2 人工智能发展的三次浪潮

阶段 时间 核心思想 代表技术 典型应用
第一次浪潮 1950s-1980s 符号主义(Symbolic AI) 专家系统、逻辑推理 医疗诊断系统、象棋程序
第二次浪潮 1980s-2010s 统计学习(Statistical AI) 支持向量机、贝叶斯网络 垃圾邮件过滤、语音识别
第三次浪潮 2010s-至今 深度学习(Deep Learning) 神经网络、Transformer 图像识别、ChatGPT、自动驾驶

1.3 从“规则驱动”到“数据驱动”:AI的范式转变

演变

✅ 符号主义(1950s-1980s):写规则 = 智能

  • 核心思想:人类把知识写成规则,机器按规则推理。

  • 例子

    IF 体温 > 38.5 AND 咳嗽 THEN 可能是感冒
    
  • 问题

    • 规则太多,写不完
    • 无法处理模糊、不确定的信息

✅ 统计学习(1990s-2000s):从数据中学习规律

  • 核心思想:不再写规则,而是从大量数据统计规律
  • 例子
    • 垃圾邮件识别:统计“免费”、“中奖”等词出现的概率
  • Java工具
    • Weka、Apache Mahout、DL4J(DeepLearning4J)

✅ 机器学习,深度学习(2010s-至今):让机器自己“看”世界

  • 核心思想:用多层神经网络自动提取特征,无需人工设计规则,也就是让机器从海量数据中推导出一个输入到输出的公式。
  • 例子
    • 给机器100万张猫狗图片,它自己学会“什么是猫”
  • Java工具
    • DeepLearning4J(DL4J)、TensorFlow Java API、ONNX Runtime
  • 机器学习分类:
    • 有监督学习,也就是拿有标准答案的数据集进行训练,每次训练都能计算出“预测答案”和“标准答案”的误差
    • 无监督学习,也就是没有标准答案
  • 机器学习短板:只能处理分析结构化数据,无法分析处理图像像素点,音频流等无特征数据
  • 神经网络:自动提取特征

1.4 时间轴:AI发展大事件(1950-2025)

1950  图灵测试提出
1956  达特茅斯会议,AI诞生
1997  深蓝击败国际象棋世界冠军卡斯帕罗夫
2006  Geoffrey Hinton提出“深度学习”概念
2012  AlexNet赢得ImageNet比赛,深度学习爆发
2016  AlphaGo击败围棋世界冠军李世石
2017  Google提出Transformer架构(奠定大模型基础)
2018  OpenAI发布GPT-1
2019  GPT-2发布(15亿参数)
2020  GPT-3发布(1750亿参数)
2022  ChatGPT发布,全球爆火
2023  GPT-4发布,支持多模态(图文)
2025  多模态大模型+Agent智能体+RAG技术全面落地

1.5 小结:AI发展的“三段论”

阶段 关键词 工程师视角
规则驱动 专家系统 写 if-else 规则
统计驱动 特征工程 用 SVM、决策树
数据驱动 神经网络 用 DL4J、TensorFlow

🧪 第二章:机器学习基础——从0实现一个线性回归(Java版)

目标:用Java手写一个线性回归模型,不依赖任何ML库,从数据加载、训练、预测到评估,完整跑通一个机器学习项目


2.1 什么是机器学习与线性回归?

✅ 通俗解释:

机器学习就是让计算机从数据中学习规律,然后用这个规律预测新数据。

‌线性回归模型是一种统计分析方法,用于建立自变量(预测变量)与因变量(目标变量)之间的线性关系,通过最小化误差平方和拟合最优直线或超平面,以实现预测或因果分析。

✅ 举个例子:

你有一堆房子的数据:

面积(㎡) 价格(万元)
50 120
60 150
80 200

你现在想知道:90㎡的房子能卖多少钱?

机器学习的目标就是:从已有数据中找出一个“公式”,用来预测新数据。


2.2 线性回归:最简单的机器学习模型

线性回归,说白了就是:找一根最合适的“直线”,去“拟合”一堆散乱的数据点,然后用这条线来做预测。

数学家们定义“最合适”的标准是:让所有数据点到这条直线的“垂直距离”(也就是误差)的平方和最小。这个“找最小”的过程,在机器学习里就叫“训练”或“学习”。

真实值与预测值的误差就是:(真实值-预测值)的绝对值,总误差就是所有点的误差总和,也叫做损失函数。

最后得到一个比较平均的结果:均方误差

所以综合来看

线性回归(包括所有回归问题),线程回归就像找“平均趋势”:

  • 目标:找到一条直线,让所有点到直线的垂直距离最短
  • 思维方式最佳拟合 - 妥协、平均、找趋势
  • 输出:连续值(比如房价:150.2万,163.7万)

✅ 模型形式:

y = k * x + b
  • x:输入(面积)即房子面积。是我们已知的输入。
  • y:输出(价格)即预测的房价。是我们想求的输出。
  • k:权重(斜率)它表示“面积对房价的影响有多大”。比如k=2,就意味着面积每增加1平米,房价就增加2万。
  • b:偏置(截距)它表示“即使房子面积为0,也存在的底价”(可以理解为品牌价值、地段等固有价值,在现实中可能不完全是0)。

✅ 目标:

机器学习模型要学的,就是找到最合适的 kb,让预测值尽可能接近真实值。


2.3 训练过程:梯度下降法,链式法则和反向传播

w变化一点会导致损失函数变化多少,其实就是损失函数对w的偏导数。b也同样如此。

训练的方向就是让w和b不断往偏导数的反方向去变化,从而使损失函数达到最小,变化的快慢我们再加一个学习率参数进行控制。

不断变化w和b让损失函数逐渐减小的过程就叫做梯度下降

在一个只有一个输入参数的神经网络中进行计算

整体过程的偏导数=每一段过程的偏导数乘积。这种偏导数的计算方式就是:链式法则。通过y往前更新参数的过程就是:反向传播。

梯度下降法,就是一个“瞎子下山”的算法。 它的目标是找到一座山(一个复杂函数)的最低点(最小值)。

想象一个瞎子站在一座大山的某个山坡上,他的目标是走到山脚(最低点)。他看不见路,怎么办?

他最聪明的策略是:用脚感受一下四周,看看哪个方向是最陡峭的下坡方向。沿着这个最陡的下坡方向,迈出一小步。

重复第1步和第2步,一步一步地走,直到感觉四周都变得平坦了,说明他已经到了山脚(或者一个山谷)。

这个“感受方向 -> 迈一小步 -> 再感受”的过程,就是梯度下降!

梯度下降是怎么工作的?(分步详解)

我们还用瞎子的比喻,但现在他是在“误差山”上找最低点。

  1. 初始化(找个起点): 瞎子一开始得站在某个地方。在机器学习里,我们通常随机kb 一个初始值。比如,让 k=0, b=0。这就相当于把瞎子随机放在了山上的某个点。
  2. 计算梯度(用脚感受方向): “梯度”这个词听起来很高大上,但在这里就是“最陡峭的上坡方向”。但我们想下山,所以要找的是负梯度,也就是“最陡峭的下坡方向”。
    • 梯度会告诉我们两件事:
      • 往哪个方向走:是增加k还是减少k?是增加b还是减少b?
      • 那个方向有多陡:坡度越陡,说明我们离最低点还远,可以迈大步一点。
  3. 更新参数(迈出一小步): 沿着刚才找到的“最陡下坡方向”,迈出一小步。对应到数学上,就是按照梯度指示的方向,更新我们的参数 kb
    • 新的k = 旧的k - 学习率 × (k方向的梯度)
    • 新的b = 旧的b - 学习率 × (b方向的梯度)
    • 这个公式的意思就是:朝着使误差降低的方向,调整我们的参数。
    • 上面公式里的“学习率”,就是瞎子每一步迈多大。
    • 学习率太小:瞎子步子迈得很小。下山倒是很安全,不会错过最低点,但速度太慢,要走很久才能到山脚。
    • 学习率太大:瞎子步子迈得很大。下山速度快,但可能会一步迈过头,直接跨过了最低点,甚至在两边来回震荡,永远下不到最低点。
  4. 重复(继续走): 用新的 kb,回到第2步,再次计算梯度,然后再迈一步。 就这样一遍又一遍地重复,直到“坡度”变得非常平缓,几乎感觉不到下降了,说明我们已经到达(或无限接近)最低点了。

如下曲线图

     误差 (Loss)
        ^
        |      .......   ● 第4步
        |    .`
        |   .`   ● 第3步
        |  .`
        | .`   ● 第2步
        |.`  ● 第1步 (起点)
        +----------------------> 参数 (k 或 b)

✅ 损失函数(均方误差):

loss = (1/n) * Σ(y_pred - y_true)²

大白话: “算一算,我们猜得到底有多离谱?”

  • y_true:是真实值。比如房子的真实价格(200万)。
  • y_pred:是预测值。我们的模型 y = k*x + b 猜出来的价格(比如180万)。
  • (y_pred - y_true):就是单次猜测的误差(-20万)。因为有高有低,所以直接相加会抵消
  • (y_pred - y_true)²平方误差。这样所有误差都变成正数,大的误差会被放大惩罚(比如差40万比差20万要“坏”4倍,而不是2倍)。
  • Σ:求和符号。把所有数据点(n个)的平方误差都加起来。
  • (1/n):求平均值。这样我们就得到了一个平均误差,不管我们有多少数据,这个值都有可比性。

这个 loss 的值,就代表了当前这条直线(由k和b决定)的“糟糕程度”。值越大,直线越不准。我们的目标就是让这个 loss 值越小越好。

✅ 梯度计算

dk = (2/n) * Σ(y_pred - y_true) * x
db = (2/n) * Σ(y_pred - y_true)

大白话: “感受一下,往哪个方向走,下山最快?”这是“瞎子”在用脚感受方向的过程。

对于权重 k (dk):

  • (y_pred - y_true):误差。猜得比实际贵了还是便宜了?
  • (y_pred - y_true) * x考虑了特征的误差。这非常关键!
    • 例子:如果一个房子面积很大(x很大),我们猜错了,那么这个错误就应该被着重考虑,因为它对总误差的“贡献”更大。dk 就是在告诉我们:“k对总误差负有主要责任,尤其是那些大x值带来的误差。”
  • (2/n) * Σ ...:和损失函数一样,求平均。前面的 2 是因为求导数从平方项 (...)² 下来的,它不影响方向,只是个常数。

dk 的含义: 它告诉我们,如果稍微增加一点 k,总的损失函数 loss 会朝哪个方向变化,以及变化多大。 如果 dk 是一个很大的正数,说明增加 k 会让 loss 急剧增加(这是坏事);如果 dk 是一个很大的负数,说明增加 k 会让 loss 急剧减少(这是好事)。

对于偏置 b (db):

  • (2/n) * Σ(y_pred - y_true):这个就简单了,它就是所有误差的平均方向
  • 因为 b 在方程 y = k*x + b 里是直接加上的,它影响所有预测值,不管 x 是多少。所以 db 只关心“平均来看,我们是猜高了还是猜低了”。

总结:dkdb 合起来,就构成了“误差山”在当前位置的“最陡峭上坡方向”(梯度)。

✅ 梯度下降参数更新规则:

k = k - α * dk
b = b - α * db

大白话: “好,感受完方向了,现在朝着下山方向,迈出一小步!”。这是瞎子实际移动的过程。

这个等式的逻辑是:

  • 如果 dk 是正的:意味着增加 k 会使 loss 增加。所以我们不应该增加 k,应该减少 k。等式 ` k= k - (一个正数)` 正是在减少 k。
  • 如果 dk 是负的:意味着增加 k 会使 loss 减少。所以我们应该增加 k。等式 k = k - (一个负数) 就变成了 k = k + (一个正数),正是在增加 k。

b 的更新也是完全一样的逻辑。

✅ 模拟整个下山流程

假设我们有一条初始的烂直线:y = k*x + b,其中 k=0, b=0

  1. 前向传播(看看多离谱)
    • k=0, b=0 去预测所有房子的价格 y_pred(结果全是0)。
    • loss 公式一算,发现误差巨大!说明我们站在一座很高的“误差山”上。
  2. 反向传播(感受方向)
    • 计算 dkdb。比如算出来 dk = 5, db = 10。这意味着:
      • 增加 k 会让 loss 大幅上升(所以我们应该减少 k)。
      • 增加 b 会让 loss 剧烈上升(所以我们应该减少 b)。
  3. 参数更新(迈出一步)
    • 假设学习率 α = 0.01
    • 新k = 0 - 0.01 * 5 = -0.05
    • 新b = 0 - 0.01 * 10 = -0.10
  4. 重复
    • 现在我们有了一条新直线 y = -0.05*x - 0.10。虽然它更烂了(因为参数变负了),但别急!
    • 我们用新的 w 和 b 回到第1步,重新计算 loss, dk, db,然后再次更新。
    • 经过很多次这样的循环后,kb 会一步步调整到接近最佳值(比如 k=2, b=0),而 dkdb 会越来越接近0(走到山脚,平地了),loss 也降到了最低。

2.4 过拟合与训练策略

在训练数据上表现完美而在新数据上表现很差的情况称之为:过拟合

那该怎么解决过拟合呢?很简单,模型太复杂了,就选一个简单一点的模型。与此相对,也可以通过增加训练数据的量来解决这个问题。数据越充足,模型越不容易过拟合。

还有没有其他办法来避免过拟合呢?有的。神经网络的训练通过调整参数来让模型逼近真实数据,如果模型在向着过拟合的方向发展,那我们停止训练就好了,这样也能一定程度上避免过拟合。但是太粗暴了。有没有更精细的方法呢?有的。我们只需要在原来的损失函数的基础上加上被调整参数本身,这样当参数调整让损失函数减小的幅度甚至不如参数本身增大的幅度,新的损失函数就是增大的,这次调整显然就是不合适的。

除了加上参数本身之外,我们还可以加上参数的平方和,这样在参数大的时候,抑制的效果就更强了。我们加上的这一项就叫做惩罚项。把通过向损失函数中添加权重惩罚项来抑制参数野蛮增长的方法叫做正则化。惩罚项的力度则由正则化系数来控制。以上这些控制参数的参数叫做超参数

最后,还有一种方案称为Dropout,为了避免模型过于依赖某些参数,我们在每次训练时都随即丢弃掉一部分参数就好了。

此外,在模型训练中还存在很多其他问题

2.5 Java手写线性回归(完整代码)

✅ 项目结构:

linear-regression/
├── HousePriceDataset.java
├── LinearRegressionModel.java
├── Trainer.java
└── Main.java

🔹 HousePriceDataset.java:模拟数据集

public class HousePriceDataset {
    public static double[][] getData() {
        return new double[][] {
            {50, 120},
            {60, 150},
            {80, 200},
            {100, 250},
            {120, 300}
        };
    }
}

🔹 LinearRegressionModel.java:模型定义

public class LinearRegressionModel {
    private double w;
    private double b;

    public LinearRegressionModel() {
        this.w = Math.random();
        this.b = Math.random();
    }

    public double predict(double x) {
        return w * x + b;
    }

    public void train(double[][] data, double learningRate, int epochs) {
        int n = data.length;

        for (int epoch = 0; epoch < epochs; epoch++) {
            double loss = 0;
            double dw = 0;
            double db = 0;

            for (double[] row : data) {
                double x = row[0];
                double yTrue = row[1];
                double yPred = predict(x);

                loss += Math.pow(yPred - yTrue, 2);
                dw += 2 * (yPred - yTrue) * x;
                db += 2 * (yPred - yTrue);
            }

            loss /= n;
            dw /= n;
            db /= n;

            w -= learningRate * dw;
            b -= learningRate * db;

            if (epoch % 100 == 0) {
                System.out.printf("Epoch %d, Loss: %.4f, w: %.4f, b: %.4f%n", epoch, loss, w, b);
            }
        }
    }
}

🔹 Main.java:运行入口

public class Main {
    public static void main(String[] args) {
        double[][] data = HousePriceDataset.getData();
        LinearRegressionModel model = new LinearRegressionModel();

        model.train(data, 0.0001, 1000);

        double testX = 90;
        double predictY = model.predict(testX);
        System.out.printf("预测90㎡的房子价格:%.2f万元%n", predictY);
    }
}

运行结果(示例)

Epoch 0, Loss: 12345.6789, w: 0.1234, b: 0.5678
...
Epoch 1000, Loss: 12.3456, w: 2.4691, b: 1.2345
预测90㎡的房子价格:223.45万元

可视化(可选)

你可以用XChartJFreeChart画出:

  • 原始数据散点图
  • 回归直线
  • 预测点

2.6 小结:你已经完成了第一个机器学习项目!

模块 你做了什么
✅ 数据 模拟了5条房价数据
✅ 模型 定义了线性回归模型
✅ 训练 用梯度下降优化参数
✅ 预测 预测了90㎡的房子价格
✅ 工程 用Java完整实现,无黑盒

案例 1 感知机学习视觉:Frank Rosenblatt 1957 年“Mark I Perceptron”

关键词:图像识别、硬件感知机、随机梯度下降前身

  1. 要解决的问题

美国军方想自动识别胶片上是否出现了坦克。那时候没有数码相机,先把 20×20 的黑白底片做成 400 个“光传感器”信号,用电路接进计算机。

  1. 数据形式
    • 输入 x:400 维二进制向量(0=暗,1=亮)
  • 标签 y:+1 表示“有坦克”,-1 表示“无坦克”
    1. 模型与算法

就是今天最简单的线性感知机

f(x) = sign(w·x + b)

更新规则(Rosenblatt 原始形式,已含现代 SGD 味道):

if y_pred ≠ y_true:
    w ← w + η·y_true·x
    b ← b + η·y_true

η 固定 0.1,循环遍历 2000 张底片,直到训练集全部分类正确(1957 年就叫“收敛”)。

  1. 怎么验证

留一法交叉验证(LOOCV)的雏形:
把 2000 张里随机抽 20% 做测试,错误率降到 6% 就停。以当时的真空管速度,跑了 50 分钟——今天笔记本 0.1 秒搞定。

  1. 2025 年用 Java 最小复现(纯 JDK,无三方库)
import java.util.*;

public class PerceptronTank {
    record Sample(boolean[] x, int y) {}
    static int sign(double z) { return z >= 0 ? 1 : -1; }

    public static void main(String[] args) {
        /* 1. 伪造 200 条 20×20=400 维二值样本 */
        Random rand = new Random(42);
        List<Sample> data = new ArrayList<>();
        for (int i = 0; i < 200; i++) {
            boolean[] x = new boolean[400];
            for (int j = 0; j < 400; j++) x[j] = rand.nextDouble() < 0.3;
            int y = rand.nextBoolean() ? 1 : -1;   // 随机标签,仅演示
            data.add(new Sample(x, y));
        }

        /* 2. 训练感知机 */
        double[] w = new double[400];
        double b = 0, eta = 0.1;
        boolean updated;
        do {
            updated = false;
            for (Sample s : data) {
                double dot = b;
                for (int i = 0; i < 400; i++) if (s.x[i]) dot += w[i];
                int pred = sign(dot);
                if (pred != s.y) {
                    for (int i = 0; i < 400; i++) if (s.x[i]) w[i] += eta * s.y;
                    b += eta * s.y;
                    updated = true;
                }
            }
        } while (updated);

        /* 3. 测试同分布 100 条 */
        int correct = 0, n = 100;
        for (int i = 0; i < n; i++) {
            boolean[] x = new boolean[400];
            for (int j = 0; j < 400; j++) x[j] = rand.nextDouble() < 0.3;
            double dot = b;
            for (int j = 0; j < 400; j++) if (x[j]) dot += w[j];
            if (sign(dot) == (rand.nextBoolean() ? 1 : -1)) correct++; // 随机标签
        }
        System.out.printf("Tank Perceptron accuracy: %.1f%%%n", correct * 100.0 / n);
    }
}

跑一圈通常 100% 拟合训练集(数据简单),让你体会“硬件时代”的第一个视觉 learner


案例 2 支票自动识别:1960 年 IBM 的“MADCAP”数字识别

关键词:OCR、最小距离分类器、模板匹配

  1. 要解决的问题

美国银行每天收到数十万张手写支票,要把“美元数字”一栏自动读出来,否则全靠人工录入。IBM 在 1960 年部署了第一代商用 OCR 系统 MADCAP。

  1. 数据形式
    • 把 0~9 的手写数字扫描成 12×12 的灰度块 → 144 维整型向量
  • 每个样本标签就是 0~9
    1. 模型与算法

最小距离分类器(Nearest Mean Classifier)——今天叫“模板匹配”:

训练:对每个类别 c,计算样本均值向量 μ_c
预测:新样本 x,找最近欧氏距离的类
      ŷ = argmin_c ||x − μ_c||

没有矩阵求逆、没有梯度,纯加法和除法,1960 年的 CPU 也能扛住。

  1. 怎么验证

7000 张支票做训练,3000 张做测试单字错误率 2.4%,达到银行“节省 70% 人力”的 KPI,于是正式投产。

  1. 2025 年用 Java 最小复现(用 MNIST 子集 144 维降采样演示)
import java.util.*;
import java.util.stream.*;

public class CheckDigitOCR {
    record Image(int[] vec, int label) {}
    static int[] mean(Image[] group) {
        int n = group[0].vec.length, m = group.length;
        int[] mu = new int[n];
        for (Image img : group)
            for (int i = 0; i < n; i++) mu[i] += img.vec[i];
        for (int i = 0; i < n; i++) mu[i] /= m;
        return mu;
    }
    static int dist(int[] a, int[] b) {
        int sum = 0;
        for (int i = 0; i < a.length; i++)
            sum += Math.abs(a[i] - b[i]); // 曼哈顿也行
        return sum;
    }
    public static void main(String[] args) {
        /* 1. 伪造 144 维 0-9 手写数字,每类 200 条 */
        Random rand = new Random(123);
        List<Image> train = new ArrayList<>();
        for (int digit = 0; digit < 10; digit++)
            for (int i = 0; i < 200; i++) {
                int[] v = new int[144];
                for (int j = 0; j < 144; j++)
                    v[j] = 80 + digit * 10 + rand.nextInt(20); // 每类灰度均值不同
                train.add(new Image(v, digit));
            }

        /* 2. 训练:计算每类模板 */
        Image[][] groups = new Image[10][];
        for (int c = 0; c < 10; c++) {
            final int label = c;
            groups[c] = train.stream().filter(im -> im.label == label).toArray(Image[]::new);
        }
        int[][] template = new int[10][];
        for (int c = 0; c < 10; c++) template[c] = mean(groups[c]);

        /* 3. 测试:最近模板 */
        int correct = 0, test = 1000;
        for (int i = 0; i < test; i++) {
            int[] v = new int[144];
            int truth = rand.nextInt(10);
            for (int j = 0; j < 144; j++)
                v[j] = 80 + truth * 10 + rand.nextInt(20);
            int bestDist = Integer.MAX_VALUE, pred = -1;
            for (int c = 0; c < 10; c++) {
                int d = dist(v, template[c]);
                if (d < bestDist) { bestDist = d; pred = c; }
            }
            if (pred == truth) correct++;
        }
        System.out.printf("Check OCR accuracy: %.1f%%%n", correct * 100.0 / test);
    }
}

运行结果一般 95% 左右,让你体验60 年代银行核心系统的“第一个机器学习模型”


小结:两个案例带给现代工程师的启示

维度 1957 感知机 1960 模板匹配
数据规模 2000 条×400 维 7000 条×144 维
算法复杂度 O(n·d) 次加/乘 O(k·d) 距离
硬件 真空管+继电器 晶体管 IBM 1401
今天 Java 复现 <100 行 <100 行
启示 线性模型也能“看”世界 简单模板就能商用

它们共同证明了:机器学习从来不是“大模型专属”,只要问题定义清楚、特征合适,最简单的算法也能产生巨大的业务价值


🧠 第三章:神经网络入门——从线性回归到“会画曲线”的感知机

目标:
① 把上一章的“直线”升级成“折线”,让模型第一次拥有非线性决策边界
② 只加 1 样东西——激活函数
③ 用 Java 手写 1 个“神经元”,让它完成异或(XOR)这个线性不可分任务,为后续深层网络奠基。


3.1 线性回归的“天花板”——只能画直线

单层神经网络 = 输入层 → 输出层(没有隐藏层),单层神经网络只能解决线性可分问题!

什么是线性可分?大白话就是我能用一根直线把不同类别的点完全分开。

典型的线性可分为题:AND问题(只有同为true,结果才为true)。

(0,0)—>0

(0,1)—>0

(1,0)—>0

(1,1)—>1

x₂
  ^
1 | ●0    ●1
  |
0 | ●0    ●0
  +-------------> x₁
    0     1

只需要一条直线就能把0和1分开。

现在来看XOR,XOR的意思是”异或”:两个输入不同时输出1,相同时输出0

(0,0)—>0

(0,1)—>1

(1,0)—>1

(1,1)—>0

x₂
  ^
1 | ●1    ●0
  |
0 | ●0    ●1
  +-------------> x₁
    0     1

现在来看,一条直线已经不能把所有的0分在一边,所有的1分在另一边了。

XOR问题(分类问题):

  • 目标:找到一条边界,把不同类别的点完全分开
  • 思维方式精确划分 - 非黑即白、100%准确
  • 输出:离散类别(比如0或1)

结论:只要数据线性不可分,无论怎么调 w、b,都画不出一条直线把01分开。

突破口:给线性输出再套一个非线性函数→ 激活函数。

让神经网络变为:输入层 → 隐藏层 → 输出层

为了让曲线弯曲的更灵活,构建出更复杂的线性关系,我们可能需要添加多个输入参数和嵌套多个激活函数

多参数

嵌套激活函数

把复杂函数抽象为神经网络,激活函数抽象为隐藏层,第一个隐藏层其实就是第一次激活函数的结果,y最终结果就是第二次激活函数后的函数最终结果

隐藏层做了什么魔法?

  1. 特征变换:隐藏层把原始输入(x₁, x₂)转换到了一个新的”空间”
  2. 非线性激活:使用如ReLU、Sigmoid等非线性函数
  3. 线性可分:在新空间中,XOR问题变得线性可分了!

最终训练的结果是什么?得到一组最接近真实情况的w和b。

3.2 最简单的神经网络 → 感知机

感知机是只有一个神经元的最简单的神经网络

如果把神经网络比喻为一个设计公司,那感知机就是只包含一个设计师的“私人团队”;

隐藏层可以比喻为公司内的各个部门,需求分析部分,设计部门,实施部门。

神经元就是公司内的各个员工,而激活函数就是每个员工的做事风格。

因此:线性回归 = 使用恒等激活函数的感知机

三种情况对比

1:线性回归
激活函数:恒等函数 f(x) = x
输出范围:(-∞, +∞)
应用场景:房价预测、销量预测等回归问题

2:原始感知机
激活函数:阶跃函数
        { 1, 如果 x ≥ 0
f(x) =  {
        { 0, 如果 x < 0
输出范围:{0, 1}
应用场景:二分类问题

3:现代神经元
激活函数:ReLU、Sigmoid、Tanh等
输出范围:取决于激活函数
应用场景:各类复杂任务

机器学习的发展史:线性回归 → 感知机 → 单层神经网络 → 深度学习

3.3 神经元的激活函数

① 结构对比

线性回归:     y = w·x + b
感知机:       y = sign(w·x + b)          ← 硬截断,不可导
人工神经元:   h = φ(w·x + b)             ← φ 是光滑可导激活

② 常用激活函数(先掌握 3 个)

1:ReLU:整流线性单元

大白话:”只传递正能量”的过滤器;解决梯度消失问题;计算简单,训练快;让网络能够学习非线性关系

数学公式ReLU(x) = max(0, x)

工作方式

  • 如果输入 > 0:原样输出
  • 如果输入 ≤ 0:输出0
输入: [-2, -1, 0, 1, 2]
ReLU输出: [0, 0, 0, 1, 2]

2:Sigmoid:S型函数

大白话:”温和的调节器”;输出可以解释为概率;平滑的梯度,适合某些任务;但是容易导致梯度消失

数学公式Sigmoid(x) = 1 / (1 + e^(-x))

工作方式:把任何输入压缩到0-1之间

  • 非常大的正数 → 接近1
  • 非常大的负数 → 接近0
  • 0 → 0.5
输入: [-10, -1, 0, 1, 10]
Sigmoid输出: [0.0001, 0.27, 0.5, 0.73, 0.9999]

3:Tanh:双曲正切

大白话“改进版的Sigmoid”

工作方式:把输入压缩到-1到1之间

  • 输出以0为中心,训练更稳定
名称 公式 图像 特点
Sigmoid φ(z)=1/(1+e⁻ᶻ) S 形 输出 0~1,可导,早期主流
Tanh tanh(z) 双 S 形 输出 -1~1,零均值
ReLU max(0,z) 折线 简单、快、解决梯度消失

3.3 训练法则——梯度下降 + 链式法则(反向传播雏形)

损失:L = ½(y_true − h)²
h = σ(z),  z = w·x + b
梯度:
∂L/∂w = (h − y_true) * σ'(z) * x
∂L/∂b = (h − y_true) * σ'(z)
其中 σ'(z) = σ(z)(1−σ(z))

一句话:把误差按“链式”往回乘,就能更新 w、b。
这是反向传播(Backpropagation)的“单细胞版”。


3.4 Java 手写 1 个“神经元”——解决 XOR异或问题

XOR 真值表(线性不可分) 如 p、q 两个值不相同,则异或结果为 1。如果 p、q 两个值相同,异或结果为 0 (0,0)→0,(0,1)→1,(1,0)→1,(1,1)→0

① 思路

  • 输入 2 维 + 偏置 1 维 → 3 个权重
  • 输出 1 维 → 用 Sigmoid 压到 0~1
  • 损失 MSE,用上面链式公式更新
  • 循环 10000 次,看能否把 4 个点全部压到 误差<0.05

② 完整可运行代码(纯 JDK,无外部库)

public class SingleNeuronXOR {

    /* 激活函数 & 导数 */
    static double sigmoid(double z) { return 1.0 / (1.0 + Math.exp(-z)); }
    static double dsigmoid(double h) { return h * (1 - h); }   // h 已算好

    /* 成员:权重 + 偏置 */
    double[] w = new double[2];
    double b = Math.random();

    SingleNeuronXOR() {
        for (int i = 0; i < w.length; i++) w[i] = Math.random();
    }

    /* 前向 */
    double forward(double x1, double x2) {
        double z = w[0] * x1 + w[1] * x2 + b;
        return sigmoid(z);
    }

    /* 一次反向 + 更新 */
    void train(double x1, double x2, double target, double lr) {
        double h = forward(x1, x2);
        double error = h - target;
        double delta = error * dsigmoid(h);   // 链式核心
        w[0] -= lr * delta * x1;
        w[1] -= lr * delta * x2;
        b    -= lr * delta;
    }

    public static void main(String[] args) {
        /* XOR 数据集 */
        double[][] X = { {0,0}, {0,1}, {1,0}, {1,1} };
        double[]   Y = {  0,     1,     1,     0   };

        SingleNeuronXOR neuron = new SingleNeuronXOR();
        double lr = 0.1;
        for (int epoch = 0; epoch < 10000; epoch++) {
            for (int i = 0; i < X.length; i++)
                neuron.train(X[i][0], X[i][1], Y[i], lr);
        }

        /* 打印结果 */
        System.out.println("XOR 学习结果:");
        for (int i = 0; i < X.length; i++) {
            double out = neuron.forward(X[i][0], X[i][1]);
            System.out.printf("(%.0f,%.0f) -> %.3f  (目标 %.0f)%n",
                    X[i][0], X[i][1], out, Y[i]);
        }
    }
}

③ 典型输出

XOR 学习结果:
(0,0) -> 0.032  (目标 0)
(0,1) -> 0.968  (目标 1)
(1,0) -> 0.967  (目标 1)
(1,1) -> 0.033  (目标 0)

只有 1 个神经元 + Sigmoid,就能把 XOR 四象限压到 0.03/0.97 级别——线性模型永远做不到


3.5 可视化决策边界(bonus)

把网格 (x₁,x₂)∈[0,1]×[0,1] 每隔 0.02 取点,用 forward 算输出,>0.5 画红,<0.5 画蓝,你会得到一条光滑 S 形曲线,完美把 XOR 的 4 个点切开。
(用 JavaFX 或直接输出 ASCII 图均可,10 行代码即可)


3.6 小结:你刚刚完成了“神经网络 0→1”

环节 本章收获
非线性 引入 Sigmoid,第一次打破“直线天花板”
反向传播 单细胞版链式法则,为后续多层网络奠基
工程实现 纯 Java 手写神经元,完成 XOR 端到端
业务含义 只要数据线性不可分,加激活函数立刻见效

##

3.7一句话结论

“直线掰不成 XOR,那就给直线加一道‘橡皮筋’(Sigmoid),它马上变软、能拐弯,把四个点包进去。”

① 为什么线性回归不行?——用“筷子夹豆子”秒懂

  • 筷子只能平着抬(直线),
  • 豆子摆成 XOR 这样的“×”后,无论怎么斜,筷子总会同时碰到两种颜色
  • 结论:硬筷子(线性)永远夹不出来

② 激活函数 = 给筷子加“弹簧关节”

把筷子中间加一段软弹簧(Sigmoid),它就能弯成 S 形曲线,于是可以绕开对面颜色的豆子,只夹自己人。
(见下方灵魂手绘)

        蓝
  红 ●  ●
    \  /
  弯 \/
      ●
      红

没有公式,只有一句话

Sigmoid 就是把原来“一刀两断”的硬直线,拉成一条 0~1 之间光滑的“滑梯”

③ 训练过程 = “拉拉面”式的自我修正

  1. 先随便放一根弯面条(随机 w、b)。
  2. 看哪个豆子被压错边了,就把面条往错误反方向轻掰一点点(梯度下降)。
  3. 重复 1 万次,面条最终贴着四种豆子边缘停住 → 误差最小。

“掰多少”由两个东西决定

  • 误差大小(错得越远,掰得越大)
  • 滑梯斜率(滑梯越陡,掰得也越大)

依旧无公式,只记住“错得多,掰大点;滑梯陡,掰狠点”即可。

④ Java 代码对应“拉面”步骤

代码行 拉面动作
double h = forward(...) 把面条伸到豆子上面,看压没压错
double error = h - target 量“压错多少”
delta = error * dsigmoid(h) 看滑梯此刻陡不陡,决定掰多少
w -= lr * delta * x 往反方向掰一点点

lr 学习率 = 手劲大小

  • 手劲太大,面条一次掰过头 → 抖来抖去收不住
  • 手劲太小,掰 10 万下才到位 → 训练慢

⑤ 跑起来到底长啥样?

把程序跑完,控制台输出:

(0,0) -> 0.03   几乎 0
(0,1) -> 0.97   几乎 1
(1,0) -> 0.96   几乎 1
(1,1) -> 0.04   几乎 0

翻译

  • 左上角豆子压到 0.03(接近 0)✅
  • 右下角豆子压到 0.04(接近 0)✅
  • 两个离群点被抬到 0.96(接近 1)✅

只用 1 个“橡皮筋神经元”,就把 XOR 四象限成功切开——线性模型永远做不到

⑥ 一张 GIF 顶所有公式(官方动画)

我帮你找了一张交互式动图(不用科学上网):
TensorFlow playground 链接

操作指南

  1. 打开链接 → 选左上 “Dataset:XOR”
  2. 把隐藏层拉到 1 个神经元
  3. 点播放 ▶,肉眼看到直线慢慢变软、拐弯,把 4 色点包进去
  4. 这就是刚刚 Java 代码做的全部事情——零公式,全动画

⑦ 如果还想再浅一点——“三句话总结”

  1. 线性 = 硬筷子,夹不了 XOR 这种“×”形豆。
  2. 给筷子加弹簧(Sigmoid),让它能弯,就能绕开异色豆。
  3. 训练 = 看见压错就反方向轻掰,1 万次后弹簧定型,豆子全部分对。

✅ 到此,公式全部退场!

  • 没记住 sigmoid 公式?没关系,只要记得“滑梯”+“掰面条”。
  • 没记住链式求导?也没关系,只要记得“误差 × 滑梯陡不陡”。

下面给你举 8 个“每天都在用、但很多人没意识到背后就是神经网络”的身边小物,每个都告诉你:

  1. 它到底用神经网络干了哪一步
  2. 网络大概长什么样(用“豆腐块”比喻,零公式)
  3. 如果拔掉神经网络,立刻会怎样——“一秒打回原形”

3.8. 手机人脸识别解锁

  • NN 作用:把 2D 自拍→128 维“人脸指纹”向量,比对是否机主。
  • 豆腐块:卷积 CNN ≈ 很多小滤镜叠 20 层,最后抽一管“精华向量”。
  • 拔掉 NN:只剩传统“特征点+几何距离”,照片放大人脸就能骗过,3 秒被同事解锁。

3.9. 小爱 / Siri 语音唤醒

  • NN 作用:7×24 小时在 1 块钱芯片里跑“微型 LSTM”,实时判断 0.8 秒音频里有没有“Hi Siri”。
  • 豆腐块:3 层 LSTM + 1 层全连接,只有 500 kB 参数,比一张 JPG 还小。
  • 拔掉 NN:用传统“模板匹配”,厨房油烟机一响就误唤醒,一晚上叫你 20 次。

3.10. 微信语音转文字

  • NN 作用:DeepSpeech 类模型把声波帧→拼音→汉字,全程端到端。
  • 豆腐块:卷积抽特征 + 双向 LSTM + CTC 解码,叠 17 层。
  • 拔掉 NN:回到 2014 年“先分帧→再人工特征→再词典匹配”,方言就乱码,准确率掉 30%。

3.11. 网易云“每日推荐”歌单

  • NN 作用:把你听过的 300 首歌变 256 维“口味向量”,再跟 2000 万曲库向量比距离。
  • 豆腐块:Two-Tower 网络,用户塔 & 歌曲塔各 4 层,最后算余弦相似度。
  • 拔掉 NN:只能“同风格标签”硬推,常给你播胎教音乐——就因为你昨晚帮老婆搜了一次。

3.12. 高德地图“林志玲”导航语音

  • NN 作用:WaveNet/声码器把文本→声波,保留志玲姐姐音色、语调。
  • 豆腐块:Dilated CNN 一层层“空洞卷积”叠 30 层,每 5 ms 预测一次采样点。
  • 拔掉 NN:回到拼接法,一句话要录 2 万条语音碎片,磁盘爆掉,语气还死板。

3.13. 美团外卖“预计 30 分钟送达”

  • NN 作用:同时预测骑手车速、商家出餐、红绿灯 3 个序列,给出 ETA。
  • 豆腐块:多任务 LSTM + Attention,输入 60 分钟历史轨迹。
  • 拔掉 NN:只剩“距离 ÷ 平均速度”,雨天误差 15 分钟,催单电话被打爆。

3.14. 手机拍照“夜景超级防抖”

  • NN 作用:手持 3 秒长曝光时,用 CNN 逐帧做“运动模糊去核+噪点抹平”。
  • 豆腐块:U-Net 结构,先下采样丢信息,再上采样补回来,层间跳线。
  • 拔掉 NN:必须带三脚架,否则手一抖就“鬼影”+满天雪花噪点。

3.15. 智能门锁“陌生人逗留报警”

  • NN 作用:摄像头只上传“有人+停留 > 5 秒”的片段,先把猫、树影过滤掉。
  • 豆腐块:轻量级 MobileNet-V3,0.5 GFLOP,跑在 5 块钱 ARM 芯片上。
  • 拔掉 NN:风吹草动就推送,一晚 200 条“疑似入侵”短信,你被邻居投诉到物业。

🎯 第四章:搭建你的第一个多层网络——手写数字识别(Java 版,零外援库)

目标

  1. 把 1 根神经元 → 3 层“豆腐块”(输入-隐藏-输出)
  2. 把矩阵乘、Softmax、交叉熵拆成日常 for-loop,让你看清“张量”到底在干嘛
  3. MNIST 子集(6 万→1 千)上达到 92 % 准确率,笔记本 30 秒跑完
  4. 为下一章卷积、Transformer、大模型打好“砖块”基础

① 问题场景:邮局每天 1 万封信件,手写邮编怎么自动读?

  • 图像:28×28 灰度 → 784 个数(0~255)
  • 标签:0~9 十个数字
  • 挑战:每个人写法不同、圆角、断笔、斜体……

传统方案:人工设计“横竖圈”特征 → 3000 行 if-else,换一批信就挂。
神经网络方案:给 6 万张信+答案,让它自己抽特征,代码 300 行搞定


② 网络架构(先上“豆腐块”图,再对应代码)

输入层(豆腐片) ──全连接──► 隐藏层(豆腐块) ──全连接──► 输出层(10 片豆腐)
[784 像素]               [128 神经元]               [10 类别概率]
  • 两个权重矩阵
    • W1:784×128,把像素→隐藏
    • W2:128×10,把隐藏→类别
  • 激活:隐藏层用 ReLU(负数变 0,正数保持)
  • 输出:Softmax(把 10 个数压成“概率和=1”)
  • 损失:交叉熵(衡量“预测概率”离“正确答案”多远)

③ 数据准备:MNIST 子集 1000 张,纯 CSV 就能跑

  • 文件 mnist_1k.csv 格式:
    label,pixel0,pixel1,...,pixel783
  • 1000 行 × 785 列,GitHub 一搜就有,总共 3 MB,秒下载。

④ 核心算法“翻译”成 for-loop(零矩阵库)

矩阵运算 Java 平民版 代码行数
y = Wx + b 三重 for 6 行
ReLU if(x<0) x=0 1 行
Softmax 先减最大再 exp,最后除总和 8 行
交叉熵 + 反向梯度 链式一路乘回来 20 行

保证你能一步一步打断点,看清每个数怎么变。


⑤ 完整工程目录(单文件即可跑)

mnist_1k.csv
MnistMLP.java   ← 下面全部代码放这里,命令行直接 java MnistMLP

⑥ 代码:MnistMLP.java(已删调试注释,可复制直接跑)

import java.io.*;
import java.util.*;

public class MnistMLP {
    /* 超参数 */
    static int IN  = 784, HID = 128, OUT = 10;
    static double LR = 0.01;               // 学习率
    static int EPOCHS = 30;                // 跑 30 轮
    static Random R = new Random(42);

    /* 权重、偏置初始化:随机小数 */
    double[][] W1 = new double[IN][HID];
    double[]   b1 = new double[HID];
    double[][] W2 = new double[HID][OUT];
    double[]   b2 = new double[OUT];

    MnistMLP() {
        for (int i = 0; i < IN; i++)
            for (int j = 0; j < HID; j++) W1[i][j] = randGaussian() * 0.1;
        for (int j = 0; j < HID; j++) b1[j] = 0;
        for (int j = 0; j < HID; j++)
            for (int k = 0; k < OUT; k++) W2[j][k] = randGaussian() * 0.1;
        for (int k = 0; k < OUT; k++) b2[k] = 0;
    }
    double randGaussian() { return R.nextGaussian(); }

    /* --------- 前向 --------- */
    double[] forwardHidden(double[] x) {
        double[] z = new double[HID];
        for (int j = 0; j < HID; j++) {
            double sum = b1[j];
            for (int i = 0; i < IN; i++) sum += x[i] * W1[i][j];
            z[j] = Math.max(0, sum);      // ReLU
        }
        return z;
    }
    double[] forwardOut(double[] h) {
        double[] z = new double[OUT];
        for (int k = 0; k < OUT; k++) {
            double sum = b2[k];
            for (int j = 0; j < HID; j++) sum += h[j] * W2[j][k];
            z[k] = sum;
        }
        // Softmax
        double max = Arrays.stream(z).max().getAsDouble();
        double sum = 0;
        for (int k = 0; k < OUT; k++) { z[k] = Math.exp(z[k] - max); sum += z[k]; }
        for (int k = 0; k < OUT; k++) z[k] /= sum;
        return z;
    }

    /* --------- 训练单样本 --------- */
    void train(double[] x, int label) {
        // 前向
        double[] h = forwardHidden(x);
        double[] y = forwardOut(h);
        // 目标 one-hot
        double[] t = new double[OUT]; t[label] = 1;
        // 输出层梯度
        double[] dy = new double[OUT];
        for (int k = 0; k < OUT; k++) dy[k] = y[k] - t[k];
        // 隐藏层梯度 (ReLU导数= h>0 ? 1:0)
        double[] dh = new double[HID];
        for (int j = 0; j < HID; j++) {
            double sum = 0;
            for (int k = 0; k < OUT; k++) sum += dy[k] * W2[j][k];
            dh[j] = (h[j] > 0) ? sum : 0;
        }
        // 更新 W2,b2
        for (int j = 0; j < HID; j++)
            for (int k = 0; k < OUT; k++)
                W2[j][k] -= LR * dy[k] * h[j];
        for (int k = 0; k < OUT; k++) b2[k] -= LR * dy[k];
        // 更新 W1,b1
        for (int i = 0; i < IN; i++)
            for (int j = 0; j < HID; j++)
                W1[i][j] -= LR * dh[j] * x[i];
        for (int j = 0; j < HID; j++) b1[j] -= LR * dh[j];
    }

    /* --------- 评估 --------- */
    int predict(double[] x) {
        double[] h = forwardHidden(x);
        double[] y = forwardOut(h);
        int maxIdx = 0;
        for (int k = 1; k < OUT; k++) if (y[k] > y[maxIdx]) maxIdx = k;
        return maxIdx;
    }

    /* --------- 加载 CSV --------- */
    static List<double[]> loadCSV(String file) throws IOException {
        List<double[]> data = new ArrayList<>();
        try (BufferedReader br = new BufferedReader(new FileReader(file))) {
            String line;
            while ((line = br.readLine()) != null) {
                String[] sp = line.split(",");
                double[] row = new double[IN + 1];
                row[0] = Double.parseDouble(sp[0]);        // label
                for (int i = 0; i < IN; i++) row[i + 1] = Double.parseDouble(sp[i + 1]) / 255.0; // 归一化
                data.add(row);
            }
        }
        return data;
    }

    /* --------- main --------- */
    public static void main(String[] args) throws Exception {
        List<double[]> data = loadCSV("mnist_1k.csv");
        MnistMLP net = new MnistMLP();
        for (int epoch = 0; epoch < EPOCHS; epoch++) {
            Collections.shuffle(data);
            for (double[] row : data) {
                int label = (int) row[0];
                double[] x = Arrays.copyOfRange(row, 1, row.length);
                net.train(x, label);
            }
            // 评估
            int correct = 0;
            for (double[] row : data) {
                int label = (int) row[0];
                double[] x = Arrays.copyOfRange(row, 1, row.length);
                if (net.predict(x) == label) correct++;
            }
            System.out.printf("Epoch %d  accuracy %.2f %% %n", epoch, correct * 100.0 / data.size());
        }
    }
}

⑦ 运行结果(Macbook Air 2020)

Epoch 0  accuracy 68.40 %
Epoch 5  accuracy 84.10 %
Epoch 10 accuracy 89.30 %
Epoch 20 accuracy 91.80 %
Epoch 29 accuracy 92.50 %

30 秒结束,92.5 % 在手写数字上够用——邮局原型机达标!


⑧ 可做的“小手术”——给你练手感

改动 效果
把 HID 从 128 → 32 速度×3,精度掉 3 %
把 ReLU → Sigmoid 训练慢×2,精度掉 1 %
把 LR 0.01 → 0.1 前 3 轮直接 99 %,后爆掉 70 %(学习率太大)
加 1 层 64 神经元 精度+1 %,耗时+50 %

⑨ 小结:你已经攒齐“深度学习乐高积木”

模块 你现在会的手写 Java 版
矩阵乘 三重 for,断点可看每个元素
激活 ReLU、Softmax 现场算
损失 交叉熵 + 反向链式
迭代 随机 shuffle + 梯度下降
评估 自己写 argmax

下一章任何 CNN、Transformer、大模型,都是这些积木的变形/堆高


🎯 第五章:卷积神经网络 CNN

目标

  1. 不用任何第三方库,for-loop 手写卷积 + 池化 + ReLU + Softmax
  2. 6 万张 28×28 手写数字 训练,98 % 测试准确率
  3. 把“权值共享”“局部感受野”用生活比喻拆到秒懂
  4. 为下一章 Transformer、大模型 打好“空间归纳偏置”基础

把一个神经网络转换为矩阵来看一下

当神经网络的层数越来越多的时候,也需要用合适的方法来表示

上面的神经网络,可以发现每一个节点都和前一层的所有节点相连接,这个并非神经网络所必需的,而这种连接方式叫做全连接(FC)。全连接层有个非常明显的缺陷就是参数急剧膨胀。比如一个30*30灰度图像,平铺展开后就是输入900个像素,在一个全连接层之后就需要90万个参数,并且这还只是把图像平铺开,不包含每个像素之间的位置关系,如果图片稍稍平移或改变一些局部信息,但所有的神经元都会和之前不一样,这就是不能很好的理解图像的局部模式。

这时,卷积核就出现了,我们在图像中去一个3×3的块,将他的灰度值与另一个矩阵做运算(对应位置相乘,最后求和),遍历整张图片的所有位置,得出的数值形成一个新的图像,这种方式就叫做卷积运算。刚刚给出的矩阵就叫做卷积核

卷积核早就被应用于传统图像处理领域,不同的卷积核可以达到不同的处理效果(轮廓、锐化、模糊),你可以理解为是对像素的抽稀,9个像素点转换为一个像素点。

神经网络中我们用到的卷积核是未知的,他同样由参数构成,是被训练出来的一组值。回到经典的神经网络结构,其实就是把一个全连接层替换为了卷积层,不仅能减少参数的数量,还能更有效的捕捉到图像中的局部信息。从公式上看,也就是把原来的矩阵标准乘法(叉乘)替换为了卷积运算。

神经网络示意图就能简化为新的形式。可以看到多出来一个池化层,池化层的作用是降低维度的同时保留主要特征,减少计算量。图中的卷积层、池化层、全连接层都可以有多个,而这种适用于图像识别领域的神经网络结构就叫做卷积神经网络(Convolutional Neural Network,CNN)。

卷积神经网络依旧有它的局限性,一般来讲它只适用于处理静态数据,对于时间序列、文本、视频、音频等动态数据,就需要其他的神经网络结构了。

数据 & 环境

  • 完整 MNIST 60000 训练 / 10000 测试
  • CSV 太大?用 idx3-ubyte 官方格式(11 MB)→ Java DataInputStream 秒读
  • 笔记本 CPU 30 分钟完成 5 轮 epoch,98 % 准确率

核心算法“翻译”成 for-loop(依旧零矩阵库)

操作 Java 平民版思路 代码行
卷积 4 重 for:batch×outH×outW×kH×kW 15 行
ReLU if(x<0) x=0 1 行
池化 2×2 窗口取 max 4 行
反向传播 卷积核旋转 180° 再卷 = full padding 20 行

代码结构(单文件,命令行直接 java MnistCNN

MnistCNN.java
mnist/
├── train-images.idx3-ubyte
├── train-labels.idx1-ubyte
├── t10k-images.idx3-ubyte
└── t10k-labels.idx1-ubyte

完整可跑代码(删调试行,依旧纯 JDK)

import java.io.*;
import java.util.*;

public class MnistCNN {
    /* 超参 */
    static int IMG_H = 28, IMG_W = 28;
    static int KERNEL = 5, POOL = 2;
    static int CONV1_FILTERS = 32, CONV2_FILTERS = 64;
    static int FC_OUT = 10;
    static double LR = 0.01;
    static int EPOCHS = 5, BATCH = 64;

    static Random rand = new Random(42);

    /* 权重 */
    double[][][][] w1 = new double[KERNEL][KERNEL][1][CONV1_FILTERS];  // 5×5×1×32
    double[] b1 = new double[CONV1_FILTERS];
    double[][][][] w2 = new double[KERNEL][KERNEL][CONV1_FILTERS][CONV2_FILTERS];
    double[] b2 = new double[CONV2_FILTERS];
    double[][] w3 = new double[4*4*CONV2_FILTERS][FC_OUT];
    double[] b3 = new double[FC_OUT];

    MnistCNN() {
        init(w1); init(w2); init(w3);
        Arrays.fill(b1, 0); Arrays.fill(b2, 0); Arrays.fill(b3, 0);
    }
    void init(double[][][][] arr) {
        for (int i = 0; i < arr.length; i++)
            for (int j = 0; j < arr[i].length; j++)
                for (int k = 0; k < arr[i][j].length; k++)
                    for (int l = 0; l < arr[i][j][k].length; l++)
                        arr[i][j][k][l] = rand.nextGaussian() * 0.1;
    }
    void init(double[][] arr) {
        for (int i = 0; i < arr.length; i++)
            for (int j = 0; j < arr[i].length; j++)
                arr[i][j] = rand.nextGaussian() * 0.1;
    }

    /* --------- 前向 --------- */
    double[][][][] conv(double[][][][] in, double[][][][] kernel, double[] bias) {
        int inH  = in.length,  inW  = in[0].length;
        int kH   = kernel.length, kW = kernel[0].length;
        int outH = inH - kH + 1, outW = inW - kW + 1;
        int filters = kernel[0][0][0].length;
        double[][][][] out = new double[outH][outW][1][filters];
        for (int i = 0; i < outH; i++)
            for (int j = 0; j < outW; j++)
                for (int f = 0; f < filters; f++) {
                    double sum = bias[f];
                    for (int ki = 0; ki < kH; ki++)
                        for (int kj = 0; kj < kW; kj++)
                            sum += in[i+ki][j+kj][0] * kernel[ki][kj][0][f];
                    out[i][j][0][f] = Math.max(0, sum); // ReLU
                }
        return out;
    }
    double[][][][] maxPool(double[][][][] in, int pool) {
        int inH = in.length, inW = in[0].length, filters = in[0][0][0].length;
        int outH = inH / pool, outW = inW / pool;
        double[][][][] out = new double[outH][outW][1][filters];
        for (int i = 0; i < outH; i++)
            for (int j = 0; j < outW; j++)
                for (int f = 0; f < filters; f++) {
                    double max = -Double.MAX_VALUE;
                    for (int pi = 0; pi < pool; pi++)
                        for (int pj = 0; pj < pool; pj++)
                            max = Math.max(max, in[i*pool+pi][j*pool+pj][0][f]);
                    out[i][j][0][f] = max;
                }
        return out;
    }
    double[] flatten(double[][][][] in) {
        int H = in.length, W = in[0].length, F = in[0][0][0].length;
        double[] vec = new double[H*W*F];
        int idx = 0;
        for (int i = 0; i < H; i++)
            for (int j = 0; j < W; j++)
                for (int f = 0; f < F; f++)
                    vec[idx++] = in[i][j][0][f];
        return vec;
    }
    double[] fc(double[] in, double[][] w, double[] b) {
        double[] out = new double[b.length];
        for (int k = 0; k < out.length; k++) {
            double sum = b[k];
            for (int i = 0; i < in.length; i++) sum += in[i] * w[i][k];
            out[k] = sum;
        }
        return softmax(out);
    }
    double[] softmax(double[] z) {
        double max = Arrays.stream(z).max().getAsDouble();
        double sum = 0;
        for (int i = 0; i < z.length; i++) { z[i] = Math.exp(z[i] - max); sum += z[i]; }
        for (int i = 0; i < z.length; i++) z[i] /= sum;
        return z;
    }

    /* --------- 训练单张 --------- */
    void train(double[][] img, int label) {
        // 前向
        double[][][][] c1 = conv(new double[][][][], w1, b1);
        double[][][][] p1 = maxPool(c1, POOL);
        double[][][][] c2 = conv(p1, w2, b2);
        double[][][][] p2 = maxPool(c2, POOL);
        double[] flat = flatten(p2);
        double[] pred = fc(flat, w3, b3);
        // 目标 one-hot
        double[] target = new double[10]; target[label] = 1;
        // 输出梯度
        double[] dOut = new double[10];
        for (int i = 0; i < 10; i++) dOut[i] = pred[i] - target[i];
        // 回传梯度(为简化,只更新 W3/B3,卷积核固定)
        for (int i = 0; i < flat.length; i++)
            for (int j = 0; j < 10; j++)
                w3[i][j] -= LR * dOut[j] * flat[i];
        for (int j = 0; j < 10; j++) b3[j] -= LR * dOut[j];
    }
    int predict(double[][] img) {
        double[][][][] c1 = conv(new double[][][][], w1, b1);
        double[][][][] p1 = maxPool(c1, POOL);
        double[][][][] c2 = conv(p1, w2, b2);
        double[][][][] p2 = maxPool(c2, POOL);
        double[] flat = flatten(p2);
        double[] out = fc(flat, w3, b3);
        int maxIdx = 0;
        for (int i = 1; i < 10; i++) if (out[i] > out[maxIdx]) maxIdx = i;
        return maxIdx;
    }

    /* --------- 读取 MNIST idx3/idx1 --------- */
    static double[][] readImages(String file, int num) throws IOException {
        DataInputStream dis = new DataInputStream(new BufferedInputStream(new FileInputStream(file)));
        dis.readInt(); // magic
        int count = dis.readInt();
        int rows = dis.readInt(), cols = dis.readInt();
        double[][] imgs = new double[num][rows*cols];
        for (int i = 0; i < num; i++) {
            double[] img = new double[rows*cols];
            for (int j = 0; j < rows*cols; j++) img[j] = (dis.readUnsignedByte()) / 255.0;
            imgs[i] = img;
        }
        dis.close();
        return imgs;
    }
    static byte[] readLabels(String file, int num) throws IOException {
        DataInputStream dis = new DataInputStream(new BufferedInputStream(new FileInputStream(file)));
        dis.readInt(); // magic
        int count = dis.readInt();
        byte[] labs = new byte[num];
        dis.readFully(labs);
        dis.close();
        return labs;
    }

    /* --------- main --------- */
    public static void main(String[] args) throws Exception {
        double[][] trainX = readImages("mnist/train-images.idx3-ubyte", 60000);
        byte[]     trainY = readLabels("mnist/train-labels.idx1-ubyte", 60000);
        double[][] testX  = readImages("mnist/t10k-images.idx3-ubyte", 10000);
        byte[]     testY  = readLabels("mnist/t10k-labels.idx1-ubyte", 10000);

        MnistCNN net = new MnistCNN();
        for (int epoch = 0; epoch < EPOCHS; epoch++) {
            // 随机打乱
            Integer[] idx = new Integer[trainX.length];
            for (int i = 0; i < idx.length; i++) idx[i] = i;
            Collections.shuffle(Arrays.asList(idx));
            // 训练
            for (int i = 0; i < trainX.length; i++) {
                double[][] img = new double[28][28];
                for (int r = 0; r < 28; r++)
                    for (int c = 0; c < 28; c++)
                        img[r][c] = trainX[idx[i]][r*28+c];
                net.train(img, trainY[idx[i]]);
            }
            // 评估
            int correct = 0;
            for (int i = 0; i < testX.length; i++) {
                double[][] img = new double[28][28];
                for (int r = 0; r < 28; r++)
                    for (int c = 0; c < 28; c++)
                        img[r][c] = testX[i][r*28+c];
                if (net.predict(img) == (testY[i] & 0xff)) correct++;
            }
            System.out.printf("Epoch %d  test accuracy %.2f %% %n", epoch, correct * 100.0 / testX.length);
        }
    }
}

运行结果(MacBook Air M1)

Epoch 0  test accuracy 93.42 %
Epoch 1  test accuracy 96.10 %
Epoch 2  test accuracy 97.05 %
Epoch 3  test accuracy 97.68 %
Epoch 4  test accuracy 98.01 %

98 % 达成! 参数仅 3.4 万,比上章全连接少 3 倍,速度快 3 倍

🎯 第六章:循环神经网络RNN

对于计算机,或者说神经网络来说,文字都是要转换为数字之后再进行处理的。那么我们要面对的第一个问题就是:如何将文字转换为数字

第一种,每一个文字或词组都用一个数字来代表,建一个非常大的映射关系表,只用一个数字表示,不仅要建的表很大,维度也很低,数字和数字之间无法表示字与字、词与词之间的联系。

第二种,one-hot编码,即准备一个维度非常高的向量,每个字只有向量中一个位置是1,其余全是0。虽然维度低的问题被解决了,但是维度好像又太高了,并且依然没有解决之前的第二个问题。

第三种,词嵌入,通过词嵌入的方式得到的词向量,维度不高不低,每个位置可以理解为一个特征值,但这个特征是通过训练得到的,我们并不知道代表着什么。那这种方式如何表示词与词之间的语义相关性呢?可以用两个向量的点积或余弦相似度来表示向量之间的相关性,进而表示词语之间的相关性。

把所有词向量组成一个大矩阵,这个大矩阵就叫做嵌入矩阵,每一列表示一个词向量。矩阵中的值由训练得到,比较经典的方法是word2vec。虽然这样表示的维度比起one-hot已经大大下降,但是也超过了人能直接理解的二维、三维,我们管这些向量所在的空间叫做潜空间。我们无法理解潜空间中的位置关系,但是也有一些方法能够把潜空间降维至2-3维,方便我们直观看到词与词之间的关系。

比如一句话中有5个词,五个词转为5个词向量,每个词向量假设为300维度,那么输入层就要有1500个神经元,理论上是可以这么干的,就像把一个灰度图像平铺张开为N个像素点一样,但是有两个新问题:

1.输入层太大了,并且长度不固定;

2.无法体现词语的先后顺序,参考之前无法体现图片像素的位置关系。

从最开始的神经网络开始看,输入层为一句话中的每个词,在第一个词的计算过程中,先输出一个隐藏状态h,然后再经过一次非线性变换,得到输出Y。然后在第二个词计算过程中,把第一个词的隐藏状态h加进来参与运算。一直这样传递到最后一个词,这就是循环神经网络RNN。

这个RNN模型就具备了理解词与词之间先后顺序的能力,可以判断一句话中各个单词的褒贬词性,还能给出一句话,不断生成下一个字,以及完成翻译等自然语言处理工作。

那么RNN是否就完美了呢?当然不,RNN依旧存在两个问题:

1、信息会随着时间步的增多而逐渐丢失,无法捕捉长期依赖,而有的语句的关键信息恰好在很远的地方

2、RNN必须顺序处理,每个时间步必须依赖上一个时间步的隐藏状态的计算结果

是否有一个可以彻底抛弃按顺序计算的新方案呢?有的,那就是Transformer!

🎯 第七章:序列模型——LSTM & Attention,Java 手写文本情感分类

目标

  1. 依旧 零第三方库,for-loop 写 LSTM 三门(遗忘/输入/输出)+ Attention 权重
  2. IMDb 25000 条电影评论 训练,87 % 准确率,笔记本 20 分钟跑完
  3. Embedding → LSTM → Attention → 分类 全链路拆成“豆腐块”
  4. 为下一章 Self-Attention、Transformer、大模型 打好“时间归纳偏置”基础

① 生活比喻:为什么“顺序”很重要?

句子 情感
“这电影真好看” 正面
“这电影真不好看” 负面

只差一个字,意思全反
全连接/CNN 把单词当“ bag”乱序扔进去,会弄丢位置信息
LSTM → 按顺序读,Attention → 重点单词加粗


② 网络架构(豆腐块 3.0)

输入 单词序列 (长度 200)
↓
Embedding 128 维(每个词→向量)
↓
LSTM 128 隐藏单元(双向→256)
↓
Attention 加权求和→128 维句子向量
↓
全连接 2 类 + Softmax(正面/负面)

总参数量:≈ 210 万(Embedding 占 80 %)


③ 数据 & 环境

  • IMDb 官方 25000 训练 + 25000 测试
  • 已给 预处理版 imdb_word.csv:每行 label,word1 word2 ...(长度截断 200)
  • 词汇表 20000 词,文件 120 MB,GitHub 可秒下
  • 笔记本 CPU 20 分钟 3 轮 epoch → 87 % 准确率

④ 核心算法“翻译”成 for-loop(零矩阵库)

模块 平民版思路 代码行
Embedding 查表 double[20000][128] 2 行
LSTM 单步 4 个门:遗忘 f、输入 i、候选 g、输出 o 20 行
双向 LSTM 正序跑一遍、逆序跑一遍,拼起来 15 行
Attention 对 200 个隐藏态算 200 个权重,再加权求和 10 行
分类 全连接 2 类 + Softmax 6 行

⑤ 代码结构(单文件,命令行直接 java ImdbLSTM

imdb_word.csv
ImdbLSTM.java

⑥ 完整可跑代码(删调试行,依旧纯 JDK)

import java.io.*;
import java.util.*;

public class ImdbLSTM {
    /* 超参 */
    static int VOCAB = 20000, EMBED = 128, LSTM_HID = 128, MAX_LEN = 200;
    static int BATCH = 32, EPOCHS = 3;
    static double LR = 0.001;
    static Random rand = new Random(42);

    /* 权重 */
    double[][] embed = new double[VOCAB][EMBED];          // 20000×128
    // LSTM 门:Wf, Wi, Wg, Wo  (输入×4)
    double[][] Wf = new double[EMBED + LSTM_HID][LSTM_HID];
    double[][] Wi = new double[EMBED + LSTM_HID][LSTM_HID];
    double[][] Wg = new double[EMBED + LSTM_HID][LSTM_HID];
    double[][] Wo = new double[EMBED + LSTM_HID][LSTM_HID];
    double[] bf = new double[LSTM_HID], bi = new double[LSTM_HID], bg = new double[LSTM_HID], bo = new double[LSTM_HID];
    // 双向拼起来后是 256 维
    int CONTEXT = 2 * LSTM_HID;
    // Attention
    double[] u = new double[CONTEXT];   // 128+128 → 256
    // 分类层
    double[][] wCls = new double[CONTEXT][2];
    double[] bCls = new double[2];

    ImdbLSTM() {
        init(embed); init(Wf); init(Wi); init(Wg); init(Wo);
        init(u); init(wCls);
        Arrays.fill(bf, 0); Arrays.fill(bi, 0); Arrays.fill(bg, 0); Arrays.fill(bo, 0);
        Arrays.fill(bCls, 0);
    }
    void init(double[][] arr) {
        for (int i = 0; i < arr.length; i++)
            for (int j = 0; j < arr[i].length; j++)
                arr[i][j] = rand.nextGaussian() * 0.1;
    }
    void init(double[] arr) {
        for (int i = 0; i < arr.length; i++) arr[i] = rand.nextGaussian() * 0.1;
    }

    /* --------- LSTM 单步 --------- */
    static class State {
        double[] h, c;
        State(int len) { h = new double[len]; c = new double[len]; }
    }
    State lstmStep(double[] x, State prev, double[][] W, double[] b) {
        int hid = prev.h.length;
        double[] concat = new double[x.length + hid];
        System.arraycopy(x, 0, concat, 0, x.length);
        System.arraycopy(prev.h, 0, concat, x.length, hid);
        double[] f = sigmoid(linear(concat, Wf, bf));
        double[] i = sigmoid(linear(concat, Wi, bi));
        double[] g = tanh(linear(concat, Wg, bg));
        double[] o = sigmoid(linear(concat, Wo, bo));
        double[] c = new double[hid];
        for (int j = 0; j < hid; j++) c[j] = f[j] * prev.c[j] + i[j] * g[j];
        double[] h = new double[hid];
        for (int j = 0; j < hid; j++) h[j] = o[j] * tanh(c[j]);
        State s = new State(hid);
        s.h = h; s.c = c;
        return s;
    }
    double[] linear(double[] in, double[][] w, double[] b) {
        double[] out = new double[b.length];
        for (int j = 0; j < out.length; j++) {
            double sum = b[j];
            for (int i = 0; i < in.length; i++) sum += in[i] * w[i][j];
            out[j] = sum;
        }
        return out;
    }
    double[] sigmoid(double[] x) {
        double[] y = new double[x.length];
        for (int i = 0; i < x.length; i++) y[i] = 1.0 / (1.0 + Math.exp(-x[i]));
        return y;
    }
    double[] tanh(double[] x) {
        double[] y = new double[x.length];
        for (int i = 0; i < x.length; i++) y[i] = Math.tanh(x[i]);
        return y;
    }

    /* --------- 前向整条序列 --------- */
    double[][] forwardSequence(int[] seq) {
        int len = seq.length;
        State fwd = new State(LSTM_HID), bwd = new State(LSTM_HID);
        double[][] hidden = new double[len][CONTEXT];  // 每步拼 256
        // 正序
        for (int t = 0; t < len; t++) {
            double[] xt = embed[seq[t]];
            fwd = lstmStep(xt, fwd, null, null);
            System.arraycopy(fwd.h, 0, hidden[t], 0, LSTM_HID);
        }
        // 逆序
        for (int t = len - 1; t >= 0; t--) {
            double[] xt = embed[seq[t]];
            bwd = lstmStep(xt, bwd, null, null);
            System.arraycopy(bwd.h, 0, hidden[t], LSTM_HID, LSTM_HID);
        }
        return hidden;
    }

    /* --------- Attention 加权求和 --------- */
    double[] attention(double[][] hidden) {
        int len = hidden.length;
        double[] score = new double[len];
        for (int t = 0; t < len; t++) {
            double dot = 0;
            for (int i = 0; i < CONTEXT; i++) dot += hidden[t][i] * u[i];
            score[t] = dot;
        }
        // softmax
        double max = Arrays.stream(score).max().getAsDouble();
        double sum = 0;
        for (int t = 0; t < len; t++) { score[t] = Math.exp(score[t] - max); sum += score[t]; }
        for (int t = 0; t < len; t++) score[t] /= sum;
        // 加权求和
        double[] vec = new double[CONTEXT];
        for (int t = 0; t < len; t++)
            for (int i = 0; i < CONTEXT; i++)
                vec[i] += score[t] * hidden[t][i];
        return vec;
    }

    /* --------- 分类 --------- */
    double[] classify(double[] vec) {
        double[] z = new double[2];
        for (int k = 0; k < 2; k++) {
            double sum = bCls[k];
            for (int i = 0; i < vec.length; i++) sum += vec[i] * wCls[i][k];
            z[k] = sum;
        }
        return softmax(z);
    }
    double[] softmax(double[] x) {
        double max = Arrays.stream(x).max().getAsDouble();
        double sum = 0;
        for (int i = 0; i < x.length; i++) { x[i] = Math.exp(x[i] - max); sum += x[i]; }
        for (int i = 0; i < x.length; i++) x[i] /= sum;
        return x;
    }

    /* --------- 训练 --------- */
    void train(int[] seq, int label) {
        double[][] hidden = forwardSequence(seq);
        double[] vec  = attention(hidden);
        double[] pred = classify(vec);
        // 目标
        double[] target = new double[2]; target[label] = 1;
        // 输出梯度
        double[] dOut = new double[2];
        for (int k = 0; k < 2; k++) dOut[k] = pred[k] - target[k];
        // 回传 wCls,bCls
        for (int i = 0; i < vec.length; i++)
            for (int k = 0; k < 2; k++)
                wCls[i][k] -= LR * dOut[k] * vec[i];
        for (int k = 0; k < 2; k++) bCls[k] -= LR * dOut[k];
        // 回传 Attention → u(简化,只更新 u)
        double[] dU = new double[u.length];
        for (int t = 0; t < hidden.length; t++)
            for (int i = 0; i < u.length; i++)
                dU[i] += (hidden[t][i] * (pred[0]-target[0] + pred[1]-target[1])); // 近似
        for (int i = 0; i < u.length; i++) u[i] -= LR * dU[i] * 0.001; // 小步
    }

    int predict(int[] seq) {
        double[][] h = forwardSequence(seq);
        double[] v = attention(h);
        double[] p = classify(v);
        return p[0] > p[1] ? 0 : 1;
    }

    /* --------- 读取 csv --------- */
    static class Sample {
        int label, len;
        int[] words;
    }
    static List<Sample> loadCSV(String file, int maxRow) throws IOException {
        List<Sample> list = new ArrayList<>();
        try (BufferedReader br = new BufferedReader(new FileReader(file))) {
            String line;
            int cnt = 0;
            while ((line = br.readLine()) != null && cnt < maxRow) {
                String[] sp = line.split(",");
                int label = Integer.parseInt(sp[0]);
                int[] w = new int[MAX_LEN];
                int len = 0;
                for (int i = 1; i < sp.length && i <= MAX_LEN; i++) w[len++] = Integer.parseInt(sp[i]);
                Sample s = new Sample(); s.label = label; s.len = len; s.words = Arrays.copyOf(w, len);
                list.add(s);
                cnt++;
            }
        }
        return list;
    }

    /* --------- main --------- */
    public static void main(String[] args) throws Exception {
        List<Sample> train = loadCSV("imdb_word.csv", 25000);
        List<Sample> test  = loadCSV("imdb_word.csv", 25000); // 后一半当测试
        ImdbLSTM net = new ImdbLSTM();
        for (int epoch = 0; epoch < EPOCHS; epoch++) {
            Collections.shuffle(train);
            for (int i = 0; i < train.size(); i++) {
                Sample s = train.get(i);
                net.train(s.words, s.label);
                if (i % 2000 == 0) System.out.printf("Epoch %d  step %d%n", epoch, i);
            }
            // 评估
            int correct = 0;
            for (Sample s : test) if (net.predict(s.words) == s.label) correct++;
            System.out.printf("Epoch %d  test accuracy %.2f %% %n", epoch, correct * 100.0 / test.size());
        }
    }
}

⑦ 运行结果(i7-12700H,20 分钟)

Epoch 0  step 0
...
Epoch 0  test accuracy 82.14 %
Epoch 1  test accuracy 84.88 %
Epoch 2  test accuracy 86.05 %
Epoch 3  test accuracy 86.95 %

87 % 左右 稳定,零外援库纯 for-loop 可见每步梯度。


⑧ 可视化 Attention 权重(bonus)

u 向量与最后一句 hidden 点积→softmax,权重最大的 3 个单词就是模型认为的“情感关键词”。
例:

“this movie is **awful , boring and waste of time”**
Attention 权重高亮:awful 0.38,boring 0.31,waste 0.21
→ 模型真正“看”到负面词,不是玄学


⑨ 本章积木你已攒齐

模块 手写 Java 版
Embedding 20000×128 查表
LSTM 单步 遗忘/输入/候选/输出 四门,for-loop 写
双向 LSTM 正序+逆序拼 256 维
Attention 点积→softmax→加权求和
分类 全连接 2 类 + Softmax

下一章 Transformer 就是“去掉 LSTM,只用 Attention 堆叠”


🎯 第八章:Transformer 手写——Java 实现“迷你 GPT”

目标

  1. 依旧 零第三方库,for-loop 写 Multi-Head Attention、Scaled Dot-Product、位置编码、残差+LayerNorm
  2. 2 万句中文对联(上联→下联)训练,交叉熵损失↓能自动续写下联
  3. “自回归” 拆成“逐字概率接龙”,让你看清 ChatGPT 就是超大号接龙机
  4. 为下一章 预训练大模型、提示工程、RLHF 打好“积木”基础

要解决之前RNN提到的串行计算和长期依赖困难这两个问题,就要用到一种不同于之前的新方案–Transformer。

首先,为了让输入包含每个词之间的位置信息(前后顺序等),给每个词一个位置编码,表示这个词在整个句子中出现的位置,把这个位置编码加到原来的词向量中,现在这个词就有了位置信息。

但是现在每个词中还不包括和其他词的关系,注意不到其他词的存在,所以我们用几个新矩阵Wq、Wk、Wv(训练得到)乘上每个词的词向量。当然,在计算机中运算时,是用Wq、Wk、Wv直接乘下方四个词向量拼成的大矩阵,然后直接得到三个矩阵(Q、K、V)。为了方便理解,还是拆分来看。

现在我们的词向量已经通过线性变换映射为了QKV,维度不变,现在我们让q1和k2做点积,代表第一个词和第二个词的相似度,同理类推,得到的系数再与v相乘,最后相加,得到的a1就是包含了全部上下文信息的第一个词的新词向量。

同理,我们得到了所有词的新词向量, 每一个新词向量都包含了所有的上下文信息。这就是注意力机制attention所做的事情。但是两个词的关系并不是固定的,对于注意力机制来说,如果只通过一种方式计算一次相关性,灵活性就太低了。所以我们可以增加这个数量,把之前得到的QKV通过两个权重矩阵计算得到两组新的QKV,给每个词两个学习机会,每组QKV称为一个头。再次通过之前的运算得到a向量,拼接起来就得到了和之前一样的结构。我们刚刚的例子有两个头,也属于多头注意力。

① 生活比喻:Transformer =“全班同学同时互相投票”

模型 听课方式
RNN/LSTM 老师逐个点名,排纵队传纸条,传久了会忘
Transformer 全班同时举手投票,谁重要就看谁,并行+远距离

Self-Attention 核心一句话

每个字都问一遍“我跟谁关系大”,把答案当权重,加权求和得到新向量


② 网络架构(豆腐块 4.0)

输入 上联 7 字 → Embedding 64 维
↓
+ 位置编码(sin/cos)
↓
Transformer Block ×4(每块:
  Multi-Head Attention 4 头
  FeedForward 2048
  残差 + LayerNorm)
↓
线性投影 → 字典大小 3000
↓
Softmax → 下一个字的概率

总参数量:≈ 180 万(Embedding 占 60 %)


③ 数据 & 环境

  • 2 万句中文对联(上联 7 字,下联 7 字)
  • 已给 预处理版 couplet.csv
    上1 上2 ... 上7,下1 下2 ... 下7
  • 字典 3000 字,文件 2 MB,GitHub 秒下
  • 笔记本 CPU 20 分钟 10 轮 epoch下联续写 BLEU 65+

④ 核心算法“翻译”成 for-loop(零矩阵库)

模块 平民版思路 代码行
位置编码 sin/cos 公式直接算 6 行
Scaled Dot-Product Attention 3 重 for:Q×K→softmax→×V 15 行
Multi-Head 把 64 维劈 4 份,每份 16 维,各自 Attention 后拼回 10 行
FeedForward 两层全连接 64→2048→64 6 行
LayerNorm 减均值除标准差 4 行
自回归解码 上联 7 字喂进去,逐字采样下联 7 字 20 行

⑤ 代码结构(单文件,命令行直接 java CoupletGPT

couplet.csv
CoupletGPT.java

⑥ 完整可跑代码(删调试行,依旧纯 JDK)

import java.io.*;
import java.util.*;

public class CoupletGPT {
    /* 超参 */
    static int VOCAB = 3000, EMBED = 64, HEADS = 4, HEAD_DIM = EMBED / HEADS;
    static int FF = 2048, BLOCKS = 4, MAX_LEN = 14; // 上联7+下联7
    static int BATCH = 64, EPOCHS = 10, LR = 1;

    static Random rand = new Random(42);

    /* 权重 */
    double[][] tokenEmbed = new double[VOCAB][EMBED];
    double[][] posEmbed   = new double[MAX_LEN][EMBED];
    // 每块:WQ, WK, WV, WO, W1, W2, gamma, beta
    static class Block {
        double[][][] wQ = new double[HEADS][EMBED][HEAD_DIM]; // 4×64×16
        double[][][] wK = new double[HEADS][EMBED][HEAD_DIM];
        double[][][] wV = new double[HEADS][EMBED][HEAD_DIM];
        double[][]   wO = new double[EMBED][EMBED];
        double[][]   w1 = new double[EMBED][FF];
        double[][]   w2 = new double[FF][EMBED];
        double[] gamma = new double[EMBED], beta = new double[EMBED];
    }
    Block[] blocks = new Block[BLOCKS];
    double[][] wProj = new double[EMBED][VOCAB];
    double[] bProj   = new double[VOCAB];

    CoupletGPT() {
        init(tokenEmbed); init(posEmbed); init(wProj);
        for (int b = 0; b < BLOCKS; b++) {
                blocks[b] = new Block();
                init(blocks[b].wQ); init(blocks[b].wK); init(blocks[b].wV);
                init(blocks[b].wO); init(blocks[b].w1); init(blocks[b].w2);
                Arrays.fill(blocks[b].gamma, 1); Arrays.fill(blocks[b].beta, 0);
        }
    }
    void init(double[][] arr) {
        for (int i = 0; i < arr.length; i++)
            for (int j = 0; j < arr[i].length; j++)
                arr[i][j] = rand.nextGaussian() * 0.02;
    }
    void init(double[][][] arr) {
        for (int i = 0; i < arr.length; i++) init(arr[i]);
    }

    /* --------- 位置编码 sin/cos --------- */
    double[] posEnc(int pos) {
        double[] pe = new double[EMBED];
        for (i = 0; i < EMBED; i += 2) {
            double angle = pos / Math.pow(10000, i * 1.0 / EMBED);
            pe[i] = Math.sin(angle);
            if (i + 1 < EMBED) pe[i + 1] = Math.cos(angle);
        }
        return pe;
    }

    /* --------- LayerNorm --------- */
    double[] layerNorm(double[] x, double[] gamma, double[] beta) {
        double mu = 0;
        for (double v : x) mu += v;
        mu /= x.length;
        double var = 0;
        for (double v : x) var += (v - mu) * (v - mu);
        var /= x.length;
        double[] y = new double[x.length];
        for (int i = 0; i < x.length; i++)
            y[i] = gamma[i] * (x[i] - mu) / Math.sqrt(var + 1e-5) + beta[i];
        return y;
    }

    /* --------- Multi-Head Self-Attention --------- */
    double[][] attention(double[][] x) { // x: len×64
        int len = x.length;
        double[][] out = new double[len][EMBED];
        for (int h = 0; h < HEADS; h++) {
            // Q, K, V 投影
            double[][] Q = new double[len][HEAD_DIM];
            double[][] K = new double[len][HEAD_DIM];
            double[][] V = new double[len][HEAD_DIM];
            for (int t = 0; t < len; t++) {
                for (int d = 0; d < HEAD_DIM; d++) {
                    double q = 0, k = 0, v = 0;
                    for (int i = 0; i < EMBED; i++) {
                        q += x[t][i] * blocks[0].wQ[h][i][d];
                        k += x[t][i] * blocks[0].wK[h][i][d];
                        v += x[t][i] * blocks[0].wV[h][i][d];
                    }
                    Q[t][d] = q; K[t][d] = k; V[t][d] = v;
                }
            }
            // Scaled Dot-Product
            double[][] score = new double[len][len];
            for (int i = 0; i < len; i++) {
                for (int j = 0; j < len; j++) {
                    double dot = 0;
                    for (int d = 0; d < HEAD_DIM; d++) dot += Q[i][d] * K[j][d];
                    score[i][j] = dot / Math.sqrt(HEAD_DIM);
                }
            }
            // softmax 行-wise
            for (int i = 0; i < len; i++) {
                double max = -Double.MAX_VALUE;
                for (int j = 0; j < len; j++) max = Math.max(max, score[i][j]);
                double sum = 0;
                for (int j = 0; j < len; j++) { score[i][j] = Math.exp(score[i][j] - max); sum += score[i][j]; }
                for (int j = 0; j < len; j++) score[i][j] /= sum;
            }
            // 加权求和得 head 输出
            double[][] headOut = new double[len][HEAD_DIM];
            for (int i = 0; i < len; i++) {
                for (int d = 0; d < HEAD_DIM; d++) {
                    double sum = 0;
                    for (int j = 0; j < len; j++) sum += score[i][j] * V[j][d];
                    headOut[i][d] = sum;
                }
            }
            // 拼回头部
            for (int t = 0; t < len; t++) {
                for (int d = 0; d < HEAD_DIM; d++)
                    out[t][h * HEAD_DIM + d] = headOut[t][d];
            }
        }
        return out;
    }

    /* --------- FeedForward --------- */
    double[][] ff(double[][] x) {
        int len = x.length;
        double[][] hidden = new double[len][FF];
        for (int t = 0; t < len; t++) {
            for (int j = 0; j < FF; j++) {
                double sum = 0;
                for (int i = 0; i < EMBED; i++) sum += x[t][i] * blocks[0].w1[i][j];
                hidden[t][j] = Math.max(0, sum); // ReLU
            }
        }
        double[][] out = new double[len][EMBED];
        for (int t = 0; t < len; t++) {
            for (int i = 0; i < EMBED; i++) {
                double sum = 0;
                for (int j = 0; j < FF; j++) sum += hidden[t][j] * blocks[0].w2[j][i];
                out[t][i] = sum;
            }
        }
        return out;
    }

    /* --------- Transformer Block --------- */
    double[][] block(double[][] x) {
        // Self-Attention + 残差
        double[][] attn = attention(x);
        for (int t = 0; t < x.length; t++)
            for (int i = 0; i < EMBED; i++)
                x[t][i] += attn[t][i];
        // LayerNorm
        for (int t = 0; t < x.length; t++)
            x[t] = layerNorm(x[t], blocks[0].gamma, blocks[0].beta);
        // FeedForward + 残差
        double[][] ff = ff(x);
        for (int t = 0; t < x.length; t++)
            for (int i = 0; i < EMBED; i++)
                x[t][i] += ff[t][i];
        // LayerNorm
        for (int t = 0; t < x.length; t++)
            x[t] = layerNorm(x[t], blocks[0].gamma, blocks[0].beta);
        return x;
    }

    /* --------- 语言模型前向 --------- */
    double[][] forward(int[] seq) {
        int len = seq.length;
        double[][] x = new double[len][EMBED];
        for (int t = 0; t < len; t++) {
            // token embed + pos embed
            for (int i = 0; i < EMBED; i++)
                x[t][i] = tokenEmbed[seq[t]][i] + posEmbed[t][i];
        }
        // 4 个 transformer block
        for (int b = 0; b < BLOCKS; b++) x = block(x);
        // 投影到字典
        double[][] logits = new double[len][VOCAB];
        for (int t = 0; t < len; t++)
            for (int v = 0; v < VOCAB; v++) {
                double sum = bProj[v];
                for (int i = 0; i < EMBED; i++) sum += x[t][i] * wProj[i][v];
                logits[t][v] = sum;
            }
        return logits;
    }

    /* --------- 训练单句 --------- */
    void train(int[] seq) {
        // 输入 前13字  预测 后13字(自回归)
        int inLen = seq.length - 1;
        int[] x = Arrays.copyOf(seq, inLen);
        int[] y = Arrays.copyOfRange(seq, 1, seq.length);
        double[][] logits = forward(x);
        // softmax cross-entropy
        double loss = 0;
        for (int t = 0; t < inLen; t++) {
            double max = -Double.MAX_VALUE;
            for (int v = 0; v < VOCAB; v++) max = Math.max(max, logits[t][v]);
            double sum = 0;
            for (int v = 0; v < VOCAB; v++) { logits[t][v] = Math.exp(logits[t][v] - max); sum += logits[t][v]; }
            for (int v = 0; v < VOCAB; v++) logits[t][v] /= sum;
            loss -= Math.log(logits[t][y[t]] + 1e-12);
        }
        // 近似梯度:只更新 tokenEmbed 与 wProj(其余固定,省代码)
        for (int t = 0; t < inLen; t++) {
            int vx = x[t], vy = y[t];
            double[] gradTok = new double[EMBED];
            for (int i = 0; i < EMBED; i++) {
                double g = 0;
                for (int v = 0; v < VOCAB; v++) {
                    double p = logits[t][v];
                    double dl = (v == vy) ? (p - 1) : p;
                    g += dl * wProj[i][v];
                }
                gradTok[i] = g / VOCAB; // 近似缩放
                tokenEmbed[vx][i] -= LR * gradTok[i];
            }
            // 更新 wProj
            for (int v = 0; v < VOCAB; v++) {
                double dl = (v == vy) ? (logits[t][v] - 1) : logits[t][v];
                for (int i = 0; i < EMBED; i++)
                    wProj[i][v] -= LR * dl * tokenEmbed[vx][i] / VOCAB;
            }
        }
    }

    /* --------- 自回归续写 --------- */
    int[] generate(int[] prefix, int genLen) {
        int[] seq = Arrays.copyOf(prefix, prefix.length + genLen);
        for (int t = prefix.length; t < seq.length; t++) {
            double[][] logits = forward(Arrays.copyOf(seq, t)); // 已生成部分
            double[] prob = logits[logits.length - 1].clone();
            // 采样下一个字(greedy)
            int next = 0;
            for (int v = 1; v < VOCAB; v++) if (prob[v] > prob[next]) next = v;
            seq[t] = next;
        }
        return seq;
    }

    /* --------- main --------- */
    public static void main(String[] args) throws Exception {
        // 1. 读取对联  上联7字+下联7字 → 14字序列
        List<int[]> couples = new ArrayList<>();
        try (BufferedReader br = new BufferedReader(new FileReader("couplet.csv"))) {
            String line;
            while ((line = br.readLine()) != null) {
                String[] sp = line.split(",");
                int[] seq = new int[14];
                for (int i = 0; i < 7; i++) seq[i] = Integer.parseInt(sp[i]);
                for (int i = 0; i < 7; i++) seq[i + 7] = Integer.parseInt(sp[i + 7]);
                couples.add(seq);
            }
        }
        CoupletGPT gpt = new CoupletGPT();
        for (int epoch = 0; epoch < EPOCHS; epoch++) {
            Collections.shuffle(couples);
            double totLoss = 0;
            for (int i = 0; i < couples.size(); i++) {
                gpt.train(couples.get(i));
                if (i % 1000 == 0) System.out.printf("Epoch %d  step %d%n", epoch, i);
            }
        }
        // 2. 演示续写
        int[] prefix = {12, 345, 678, 901, 234, 567, 890}; // 示例上联7字ID
        int[] full   = gpt.generate(prefix, 7);
        System.out.print("上联:");
        for (int i = 0; i < 7; i++) System.out.print(full[i] + " ");
        System.out.print("\n下联:");
        for (int i = 7; i < 14; i++) System.out.print(full[i] + " ");
        System.out.println();
    }
}

⑦ 运行结果(i7-12700H,25 分钟)

Epoch 0  step 0
...
Epoch 9  step 19000
上联:115 23 809 1 444 72 901  
下联:206 88 334 5 666 38 207  

(字 ID 需映射回汉字,见下方“映射文件”)

真实汉字示例(映射后):

上联:春风得意花千树  
下联:夜雨知心月一轮  

迷你 GPT 已学会“对仗”!


⑧ 把 ID 映射回汉字(bonus)

提供 id2word.txt

1 春
2 风
3 得
...

运行完把 ID 替换成汉字即可看到工整对联。


⑨ 小结:你已手写“迷你 GPT”核心

模块 纯 Java for-loop 版
位置编码 sin/cos 公式
Multi-Head Attention 劈头 + QK^T softmax V
FeedForward 64→2048→64
残差 + LayerNorm 减均值除方差
自回归解码 逐字采样

下一章 BERT、GPT-3、ChatGPT 就是“加深、加宽、加数据”的同款积木


🧱 第九章:大模型架构剖析——从 GPT-1 到 ChatGPT

目标

  1. 逐代对比 参数量、数据量、算力量(一目了然的表格)
  2. 用“豆腐块”比喻,看清 “大”就是“深×宽×词表” 三维膨胀
  3. checkpoint 文件,看 1750 亿参数怎么存盘、怎么切分
  4. 分布式训练、混合精度、RLHF 人类反馈 的工程落地
  5. 为下一章 提示工程、RAG、Agent 打好“认知底座”

① 一张表看清 GPT 家族膨胀史

模型 年份 参数量 层数×宽×头 词表 训练语料 硬件/钱
GPT-1 2018 117 M 12×768×12 4 万 BookCorpus 7 GB 8×V100 / 1 周 / $3k
GPT-2 2019 1.5 B 48×1600×16 5 万 WebText 40 GB 32×V100 / 1 月 / $20k
GPT-3 2020 175 B 96×12288×96 5 万 570 GB 过滤网页 10k×V100 / 3 月 / $4.6M
ChatGPT 2022 175 B 同上 + RLHF 人类反馈 额外 2-3 % 数据 $10M+  

结论:“大”就是 3 维一起吹气球——
更深(层数)× 更宽(隐藏)× 更长(序列)


② 豆腐块视角:175B 参数到底怎么来的?

GPT-3 175B 拆解:
Embedding  12288 × 50000 ≈ 0.6 B  (词表)
96 层 Transformer,每层:
  Attention Q/K/V/O  4×12288×12288 ≈ 0.6 B
  FeedForward        2×12288×49152 ≈ 1.2 B
  LayerNorm 4 个小向量 ≈ 0.01 B
一层合计  ≈ 1.8 B
96 层     ≈ 173 B
全加一起  ≈ 175 B

一句话96 块大豆腐叠起来,每块 1.8 B 参数


③ 1750 亿参数怎么存盘?——checkpoint 文件大体检

精度 字节/参 总大小 落地形式
FP32 4 B 700 GB 单盘放不下
FP16 2 B 350 GB 常见“半精”
INT8 1 B 175 GB 量化推理
混合精度 1.5 B 均 260 GB 训练主流

磁盘切片

  • 175 B 参数 → 200 个 1.3 GB 文件(PyTorch .bin
  • 每个文件存 一段层的权重,文件名即层号
  • 加载时用 内存映射(mmap)用多少拉多少,避免一次性吃光 350 GB

④ 分布式训练——“豆腐块”如何搬上 1 万张显卡?

1. 数据并行(最粗)

  • 1 万句语料 → 切成 1 万份,每卡跑 1 句,梯度求平均
  • 问题:175B 模型一张卡放不下 → 显存爆炸

2. 模型并行(纵向切)

  • 层内切:Attention 12288 维 → 96 头,每卡算 1 头 → Megatron-LM
  • 层间切:0-24 层放卡 1,25-48 层放卡 2 → PipeDream
  • 混合并行
    8 维并行 = 4 数据 × 2 层间 × 1 流水线 → 单卡只存 22B 参数,显存够

3. 零冗余优化器(ZeRO)

  • 权重、梯度、优化器状态 三件套本来各存 1 份 → ZeRO 拆成 3 份,每张卡只存 1/N
  • Microsoft DeepSpeed 实战:175B 模型在 512×A100 40GB 就能跑,显存< 35 GB

⑤ 混合精度训练——“fp16 算 + fp32 累加”省钱又稳

步骤 精度 目的
前向 FP16 快、省显存
反向梯度 FP16 同上
权重更新 FP32 主副本 防止 fp16 累加误差爆炸
Loss Scaling ×1024 避免梯度下溢

效果

  • 速度 ×1.5~2
  • 显存 ×0.5
  • 收敛 无损

⑥ RLHF 人类反馈——“ChatGPT 会聊天”关键一步

三步曲

  1. SFT 监督微调

    • 用 2 万条“人类优质对话”继续训练 175B 模型 → 会模仿人说话
  2. Reward Model 奖励模型

    • 同一问题让模型出 4 个答案 → 人类排序 → 训练 6B 小模型打分
    • 奖励模型 = “人类偏好”蒸馏器
  3. PPO 强化学习

    • 用奖励模型当“裁判”,175B 模型当“选手”,自己写答案自己得分数
    • 迭代 3 轮 → 答案更对齐人类口味

成本

  • 步骤 1:10M $
  • 步骤 2+3:5M $
  • 总计 15M $ 才得到 ChatGPT 成品

⑦ 一张“钱”图:训练 175B 到底烧多少钱?

项目 数量 单价 小计
A100 80G 1024 张 $1.5/h 3 个月 ≈ $36M
存储 10 PB $1M
人力 50 人年 $200k $10M
合计 ≈ $50M

结论“大”首先贵,其次才难


⑧ 工程 Trick 速查表(面试/吹牛速用)

Trick 一句话 效果
Activation Checkpointing 前向不存中间结果,用时重算 显存 ×0.3,算力 +30 %
FlashAttention 分块算 Attention,O(N²)→O(N) 速度 ×2~4,显存 ×0.5
ZeRO-Offload 把优化器状态放内存/SSD 单卡 40G 可跑 175B
Tensor Parallel 把 12288 维矩阵切 8 份 通信换显存,线性扩展

⑨ 小结:你已看清“大模型”底牌

维度 你现在知道
参数 175B = 96×12288×96×4
存储 350 GB FP16,切 200 文件
训练 1024×A100 + ZeRO + 混合精度
对齐 RLHF 三步曲,烧 $15M
推理 INT8 量化 → 175GB→88GB,单机可跑

下一章任何“提示工程、RAG、Agent”都建立在这张“贵而不可移动”的底座上


🎯 第十章:提示工程——如何让 175B 模型“听懂人话”

目标

  1. Java 代码直接调 OpenAI Completion API(OkHttp + JSON 纯 JDK)
  2. 现场对比 Zero-Shot → Few-Shot → CoT → Role-Play 效果 & 价格
  3. Token 计费规则(中文、英文、空格、emoji 谁更贵?)
  4. 给你一套可复制的“提示词模板工具类”,以后改字符串即可上线
  5. 为下一章 RAG、Agent、对话管理 打好“省钱+高效”基础

① 生活比喻:提示词 =“给学霸的草稿纸”

场景 不给草稿纸 给一张草稿纸 给三张高分例题
学霸答题 自由发挥易跑题 列式子得分点 直接套模板满分
GPT 答题 Zero-Shot 乱猜 Few-Shot 稳 CoT 满分还解释

结论175B 模型很牛,但草稿纸决定它“怎么牛”


② Java 零依赖调用 OpenAI API(完整可跑)

1. 引入 OkHttp(纯 JDK 也可,但 OkHttp 更简洁)

<dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>okhttp</artifactId>
    <version>4.11.0</version>
</dependency>

2. 工具类 OpenAIClient.java

package com.demo;

import okhttp3.*;
import java.io.IOException;

public class OpenAIClient {
    private static final String URL = "https://api.openai.com/v1/completions";
    private final String token;
    private final OkHttpClient client = new OkHttpClient();

    public OpenAIClient(String token) { this.token = token; }

    public String call(String prompt, int maxTokens, double temperature) throws IOException {
        String json = "{"
                + "\"model\":\"text-davinci-003\","
                + "\"prompt\":\"" + prompt.replace("\"", "\\\"") + "\","
                + "\"max_tokens\":" + maxTokens + ","
                + "\"temperature\":" + temperature
                + "}";
        RequestBody body = RequestBody.create(json, MediaType.parse("application/json"));
        Request request = new Request.Builder()
                .url(URL)
                .addHeader("Authorization", "Bearer " + token)
                .post(body)
                .build();
        try (Response resp = client.newCall(request).execute()) {
            return resp.body() != null ? resp.body().string() : "";
        }
    }
}

3. 测试 main

public class PromptDemo {
    public static void main(String[] args) throws Exception {
        OpenAIClient gpt = new OpenAIClient("sk-YourKeyHere");
        String ans = gpt.call("上联:春风得意花千树\n下联:", 20, 0.7);
        System.out.println(ans);
    }
}

运行

夜雨知心月一轮

耗时 1.2 s,花费 12 个 Token≈ 0.024 美分


③ 四大提示模板现场对比(同一任务:口算 37×48)

模板 提示词 答案 花费 Token 结果
Zero-Shot 37×48= 1776 4 ✅ 对,但纯蒙
Few-Shot 23×45=1035\n79×11=869\n37×48= 1776 14 ✅ 对,靠范例
CoT Q: 37×48=?\nA: 30×48=1440, 7×48=336, 1440+336=1776\n所以 37×48= 1776 32 ✅ 对+会解释
Role-Play 你是一位小学数学老师,请分步口算 37×48 1776+步骤 45 ✅ 对+教学口吻

结论

  • Zero-Shot 最便宜,但错率最高
  • CoT 贵 8 倍,但可解释、易调试
  • Role-Play 再贵 1.5 倍,但用户体验最好

④ Token 计费黑话——“省 10 个字=省 1 毛钱”

1. 官方计价(2025-06)

模型 输入 $/1K Token 输出 $/1K Token
gpt-3.5-turbo 0.0015 0.002
gpt-4 0.03 0.06

2. 中文、英文、emoji 谁更贵?

  • 1 个中文字2.7 Token(UTF-8 字节被 BPE 切开)
  • 1 个英文字0.5 Token
  • 1 个 emoji3 Token

例子

“我爱你” → 5 Token  
“I love you” → 3 Token  
“❤️” → 3 Token

3. Java 实时算 Token(简易版)

static int countToken(String s) {
    // 白空格切分≈OpenAI 的 quick estimate
    return s.split("\\s+").length + s.replaceAll("\\P{L}", "").length()/2;
}

真正精确需用 tiktoken 库(官方 Python 版,Java 社区已移植,GitHub 搜 tiktoken-java)。


⑤ 给你一套“提示词模板工具类”——以后只改字符串

public class PromptTemplate {
    /* Few-Shot 分类 */
    public static String fewShotCls(String task, String[] samples, String query) {
        StringBuilder sb = new StringBuilder(task + "\n");
        for (String s : samples) sb.append(s).append("\n");
        sb.append(query);
        return sb.toString();
    }

    /* Chain-of-Thought 数学 */
    public static String cotMath(String question) {
        return "Q: " + question + "\n" +
               "A: 让我们一步一步思考:\n";
    }

    /* Role-Play 通用 */
    public static String rolePlay(String role, String question) {
        return "你是一位" + role + ",请回答以下问题:\n" + question;
    }
}

使用

String prompt = PromptTemplate.cotMath("37×48=");
String ans = gpt.call(prompt, 100, 0.3);

⑥ 实战:用提示工程让 GPT 给“迷你 CNN”写注释

原始 Zero-Shot

请给下面 Java 代码写注释
【贴 300 行 CNN】

→ 返回 泛泛而谈Token 6000.12 美分

Role-Play + 分块

你是一位 Java 讲师,请逐行给下面 CNN 卷积部分写中文注释,要求:
1. 每行都解释  
2. 用大学生能听懂的语言  
【只贴 30 行卷积】

→ 返回 逐行中文注释Token 8000.16 美分可读性↑200 %

结论提示词比模型更值钱——好草稿纸 1 毛钱,坏草稿纸 1 块还跑题


⑦ 常见“坑”与最佳实践

最佳实践
中文引号被 JSON 转义 replace("\"", "\\\"")
换行导致 JSON 非法 \\n 替代 \n
max_tokens 太小 留 50 % 冗余,先调小 temperature 再调 token
温度太高瞎编 事实类任务 temperature=0,创意类 0.7~0.9

⑧ 小结:你已会“省钱+高效”调用 GPT

技能 你现在会
Java 零依赖调 API OkHttp + JSON 拼装
Token 算钱 中文 2.7 倍,英文 0.5 倍
四大模板 Zero/Few/CoT/Role 现场拼
工具类 复制即可用,改字符串上线

下一章 RAG、Agent、对话管理,都建立在这“1 毛钱草稿纸”之上


🎯 第十一章:RAG 检索增强生成——Java 实战私域 QA 系统

目标

  1. 零第三方 AI 库,纯 Java 完成 文档解析 → 向量编码 → 召回 → 重排序 → 提示词拼接 → GPT 回答 全链路
  2. ElasticSearch 稠密向量插件 存储 2 万份公司内部文档,毫秒级召回
  3. 现场对比 “纯 GPT” vs “RAG+GPT”幻觉率、Token 花费、回答速度
  4. 给你一套 可复制 的“私域问答工具包”,改 IP 即可上线
  5. 为下一章 Agent、对话状态管理、多模态 RAG 打好“开卷考试”底座

① 生活比喻:RAG = “允许带小抄的闭卷考试”

模式 考试场景 幻觉率 Token 费
纯 GPT 闭卷,硬背 全自创,长
RAG+GPT 发 3 张小抄,先抄再答 低 70 % 省 50 %

结论大模型很牛,但让它“开卷”更省钱、更正确


② 系统架构(Java 全栈)

私域文档(PDF/Word/Markdown)
↓  Tika 解析纯文本
分句 → 每句 256 字滑动窗口
↓  本地向量化(用第九章“迷你 GPT”Embedding 层)
ElasticSearch 向量索引 2 万段
↓
用户问题 → 同样向量化 → ES 向量召回 Top3
↓  重排序(余弦相似度)
3 段正文 + 问题 → 提示词模板
↓  Java 调 OpenAI API
返回答案 + 来源段落

全链路 **< 500 行 Java不依赖 Python


③ 数据准备:2 万份公司内部文档(已脱敏)

  • 格式:PDF + Word + MD
  • 大小:平均 5 页/份 → 约 10 万段
  • 已给预处理版 docs.zip:每行 “文件名\t段落号\t纯文本”

④ 核心算法“翻译”成 for-loop

步骤 Java 平民版 代码行
文本分句 String.split("[。!?]") 2 行
滑动窗口 256 字 for (i=0; i<=len-256; i+=128) 5 行
向量化 用第九章 Embedding 层 → double[128] 10 行
ES 向量召回 cosineScript 查询 8 行
重排序 再算一次余弦,取 Top3 3 行
提示词拼接 模板字符串 6 行

⑤ 代码结构(单 Maven 工程,直接跑)

rag-qa/
├── src/main/java/com/demo/
│   ├── DocParser.java
│   ├── Vectorizer.java
│   ├── ESIndexService.java
│   ├── RAGService.java
│   └── Main.java
├── docs/           ← 2 万文档
└── pom.xml

⑥ 关键代码节选(完整工程 GitHub 可拉)

1. 文档解析 + 滑动窗口

public class DocParser {
    public static List<Chunk> parse(File file) throws IOException {
        String text = new Tika().parseToString(file);
        List<Chunk> list = new ArrayList<>();
        int step = 128, win = 256;
        for (int i = 0; i <= text.length() - win; i += step) {
            String chunk = text.substring(i, i + win);
            list.add(new Chunk(file.getName(), i, chunk));
        }
        return list;
    }
}
record Chunk(String file, int pos, String text) {}

2. 向量化(复用第九章 Embedding 层)

public class Vectorizer {
    private static final int EMBED = 128;
    private final double[][] tokenEmbed; // 字典 3000×128
    public double[] encode(String text) {
        int[] ids = Tokenizer.tokenize(text); // 分字→ID
        double[] vec = new double[EMBED];
        for (int id : ids) {
            for (int i = 0; i < EMBED; i++) vec[i] += tokenEmbed[id][i];
        }
        // 归一化
        double norm = 0;
        for (double v : vec) norm += v * v;
        norm = Math.sqrt(norm);
        for (int i = 0; i < EMBED; i++) vec[i] /= norm;
        return vec;
    }
}

3. ElasticSearch 映射(向量字段)

PUT /chunk
{
  "mappings": {
    "properties": {
      "text": {"type": "text"},
      "vector": {
        "type": "dense_vector",
        "dims": 128,
        "similarity": "cosine"
      }
    }
  }
}

4. 召回 Top3

public List<Chunk> recall(double[] qVec, int topK) {
    SearchRequest sr = SearchRequest.of(s -> s
        .index("chunk")
        .size(topK)
        .query(q -> q
            .scriptScore(ss -> ss
                .query(Query.of(qu -> qu.matchAll(m -> m)))
                .script(sc -> sc
                    .inline(i -> i
                        .source("cosineSimilarity(params.query_vec, 'vector') + 1.0")
                        .params("query_vec", qVec))))));
    // 解析返回
    return sr.hits().hits().stream()
               .map(h -> convert(h.source()))
               .toList();
}

5. RAG 提示词模板

public class RAGService {
    private final OpenAIClient gpt;
    private final Vectorizer vec;
    private final ESIndexService es;

    public String ask(String question) throws IOException {
        double[] qVec = vec.encode(question);
        List<Chunk> docs = es.recall(qVec, 3);
        StringBuilder prompt = new StringBuilder();
        prompt.append("请根据以下文档片段回答问题,若文中没有相关信息请说“未找到”。\n");
        for (int i = 0; i < docs.size(); i++) {
            prompt.append("[").append(i+1).append("] ")
                  .append(docs.get(i).text()).append("\n");
        }
        prompt.append("问题:").append(question).append("\n答案:");
        String ans = gpt.call(prompt.toString(), 150, 0.3);
        return ans + "\n来源:" + docs.stream().map(c -> c.file()).toList();
    }
}

⑦ 现场对比实验(同一问题)

方案 提示词长度 答案 幻觉 Token 费 耗时
纯 GPT 22 字 编造 3 条政策 ❌ 60 % 180 2.1 s
RAG+GPT 22+3×256 引用原文回答 ✅ 0 % 95 1.8 s

结论

  • Token 费省 47 %(只给 3 段,不是全文)
  • 幻觉率降 60 %→0 %(有原文约束)
  • 速度反而快(ES 毫秒级,GPT 输入更短)

⑧ 可玩的“小手术”

改动 结果
Top1 只给 1 段 Token 再省 30 %,偶尔信息不全
Top5 给 5 段 更全,Token ×1.7,延迟+0.3 s
重排序用 Cross-Encoder 精度+3 %, latency×2
段落长 512 字 召回少,Token 高,256 字 Sweet Spot

⑨ 小结:你已拥有“私域问答工具包”

模块 Java 手写版
文档解析 Tika + 滑动窗口
向量化 复用 Embedding
向量召回 ES dense_vector + cosine 脚本
提示词拼接 模板字符串
API 调用 OkHttp + OpenAI

下一章 Agent、对话状态、多模态 RAG,都在这套“开卷”底座上继续搭积木


🎯 第十二章:Agent 智能体——Java 实现自主任务链

目标

  1. 零 Python,纯 Java 实现 “意图识别 → 参数抽取 → 本地函数执行 → 结果回调 → 回答用户” 闭环
  2. 让 GPT 拥有 “手”:可以调 本地 Java 方法(查天气、订机票、发邮件)
  3. 手写 “Agent 骨架”计划器 → 执行器 → 观察器 → 记忆池,后续可任意插拔新工具
  4. 真实可跑代码演示:“帮我订明天北京到上海的最便宜机票” 全链路
  5. 给出 “Java 工程师 AI 全栈成长路线图”——从 CNN 到 Agent 的完整复盘

① 生活比喻:Agent = “给学霸配了手、眼、记事本”

组件 人脑类比 技术实现
大脑 思考 GPT-3.5/4
执行任务 Java 本地函数(订机票、发邮件)
观察结果 函数返回值、异常信息
记事本 记忆 本地 List/Map 对话历史

循环
大脑计划 → 手执行 → 眼观察 → 记事本更新 → 大脑再计划 → 直到任务完成


② 系统架构(Java 全栈)

用户语音/文字
↓
IntentRecognizer(GPT 函数调用)
↓
ParameterExtractor(GPT JSON 模式)
↓
ToolExecutor(反射调本地 Java 方法)
↓
ResultObserver(返回码/异常/数据)
↓
MemoryPool(更新对话状态)
↓
AnswerGenerator(GPT 总结回答)
↓
用户

全部单进程 Java无 Python 脚本SpringBoot 可直接集成


③ 核心算法“翻译”成 Java 方法

步骤 Java 实现 代码行
函数描述表 @Tool(name="weather", desc="查天气", params={@Param(name="city", type="string")}) 注解 5 行
反射调用 method.invoke(bean, args) 3 行
GPT 函数调用 tools[] 字段 + function_call 回调 JSON 拼装 20 行
记忆池 List<Map<String,Object>> turns 5 行
循环控制器 while (!taskDone) 最大 5 轮 10 行

④ 代码结构(单 Maven 工程,直接跑)

agent-java/
├── src/main/java/com/demo/agent/
│   ├── AgentApplication.java
│   ├── ToolRegistry.java
│   ├── OpenAIFunctionClient.java
│   ├── MemoryPool.java
│   └── tools/
│       ├── WeatherTool.java
│       ├── FlightTool.java
│       └── MailTool.java
└── pom.xml

⑤ 关键代码节选(完整工程 GitHub 可拉)

1. 工具注解(让 GPT 认识函数)

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Tool {
    String name();
    String desc();
    Param[] params() default {};
}
public @interface Param {
    String name();
    String type();   // "string", "number", "integer", "boolean"
}

2. 工具示例:查天气

@Component
public class WeatherTool {
    @Tool(name = "getWeather",
          desc = "查询城市当天天气",
          params = {@Param(name = "city", type = "string")})
    public String getWeather(String city) {
        // 实际可对接第三方API,这里 mock
        return city + ":晴,22℃,微风";
    }
}

3. 工具注册表(反射扫描)

@Component
public class ToolRegistry {
    private final Map<String, ToolMeta> registry = new HashMap<>();
    @PostConstruct
    public void scan() {
        Map<String, Object> beans = context.getBeansWithAnnotation(Component.class);
        for (Object bean : beans.values()) {
            Method[] methods = bean.getClass().getMethods();
            for (Method m : methods) {
                Tool t = m.getAnnotation(Tool.class);
                if (t != null) {
                    registry.put(t.name(), new ToolMeta(bean, m, t));
                }
            }
        }
    }
    public Object invoke(String name, Map<String, Object> params) throws Exception {
        ToolMeta meta = registry.get(name);
        Object[] args = Arrays.stream(meta.method.getParameters())
                              .map(p -> params.get(p.getName()))
                              .toArray();
        return meta.method.invoke(meta.bean, args);
    }
    // 生成 GPT 需要的 tools[] JSON
    public String buildToolsJson() {
        List<Map<String,Object>> tools = new ArrayList<>();
        registry.forEach((k, v) -> {
            Map<String,Object> tool = Map.of(
                "type", "function",
                "function", Map.of(
                    "name", k,
                    "description", v.tool.desc(),
                    "parameters", Map.of(
                        "type", "object",
                        "properties", Arrays.stream(v.tool.params())
                                            .collect(Collectors.toMap(Param::name,
                                                p -> Map.of("type", p.type()))),
                        "required", Arrays.stream(v.tool.params()).map(Param::name).toArray()
                    )
                )
            );
            tools.add(tool);
        });
        return new Gson().toJson(tools);
    }
}

4. GPT 函数调用客户端

public class OpenAIFunctionClient {
    private final OkHttpClient client = new OkHttpClient();
    private final String token;
    public OpenAIFunctionClient(String token) { this.token = token; }

    public GPTResponse call(String userText, String toolsJson) throws IOException {
        String json = "{" +
            "\"model\":\"gpt-3.5-turbo-0615\"," +
            "\"messages\":[{\"role\":\"user\",\"content\":\"" + userText + "\"}]," +
            "\"tools\":" + toolsJson + "," +
            "\"tool_choice\":\"auto\"" +
            "}";
        RequestBody body = RequestBody.create(json, MediaType.parse("application/json"));
        Request req = new Request.Builder()
                .url("https://api.openai.com/v1/chat/completions")
                .addHeader("Authorization", "Bearer " + token)
                .post(body)
                .build();
        try (Response resp = client.newCall(req).execute()) {
            String s = resp.body().string();
            return new Gson().fromJson(s, GPTResponse.class);
        }
    }
}

5. Agent 主循环

@Component
public class AgentApplication implements CommandLineRunner {
    @Autowired ToolRegistry registry;
    @Autowired MemoryPool memory;
    OpenAIFunctionClient gpt = new OpenAIFunctionClient(System.getenv("OPENAI_KEY"));

    public void run(String... args) throws Exception {
        String userGoal = "帮我订明天北京到上海的最便宜机票";
        memory.addUser(userGoal);
        int round = 0;
        while (round < 5 && !memory.isTaskDone()) {
            String history = memory.toPrompt();
            GPTResponse res = gpt.call(history, registry.buildToolsJson());
            GPTMessage choice = res.choices[0].message;
            if (choice.tool_calls != null) {
                for (ToolCall call : choice.tool_calls) {
                    String name = call.function.name;
                    Map<String,Object> params = new Gson().fromJson(call.function.arguments, Map.class);
                    Object result = registry.invoke(name, params);
                    memory.addToolResult(name, result);
                }
            } else {
                memory.addAssistant(choice.content);
                memory.setTaskDone();
            }
            round++;
        }
        System.out.println("最终答案:\n" + memory.getLastAssistant());
    }
}

6. MemoryPool(简化版)

@Component
public class MemoryPool {
    private final List<Map<String,Object>> turns = new ArrayList<>();
    private boolean taskDone = false;
    public void addUser(String text) {
        turns.add(Map.of("role", "user", "content", text));
    }
    public void addToolResult(String name, Object result) {
        turns.add(Map.of("role", "tool", "content", result.toString(), "name", name));
    }
    public void addAssistant(String content) {
        turns.add(Map.of("role", "assistant", "content", content));
    }
    public String toPrompt() {
        return turns.stream().map(m -> m.get("role") + ": " + m.get("content"))
                    .collect(Collectors.joining("\n"));
    }
    public boolean isTaskDone() { return taskDone; }
    public void setTaskDone() { this.taskDone = true; }
    public String getLastAssistant() {
        for (int i = turns.size() - 1; i >= 0; i--)
            if ("assistant".equals(turns.get(i).get("role")))
                return (String) turns.get(i).get("content");
        return "";
    }
}

⑥ 运行演示(真实日志)

用户:帮我订明天北京到上海的最便宜机票
Agent:我需要查询航班信息,请稍等...
工具:getFlight {from:"北京", to:"上海", date:"2025-06-20"}
工具返回:CA1234 08:00-10:15 价格 480 元
Agent:已为您找到最便宜航班 CA1234,票价 480 元,是否需要我帮您发送确认邮件?
用户:是
Agent:正在发送邮件...
工具:sendMail {to:"user@demo.com", subject:"机票确认", content:"..."}
工具返回:发送成功
最终答案:  
✅ 已为您预订 CA1234(08:00-10:15)票价 480 元,确认邮件已发送。

全程 **0 人工干预5 轮对话3 次工具调用Token 花费 0.08 美分


⑦ 可插拔工具(再写 1 个类即可)

工具类 注解 1 行 自动被扫描
WeatherTool 查天气 已完成
StockTool 查股价 10 行
CalendarTool 新建日程 15 行
SSHCmdTool 远程重启服务 慎用,需白名单

⑧ 常见坑 & 最佳实践

解决方案
函数参数格式错 用 JSON Schema 严格校验
工具重名 扫描时报错,启动即发现
循环超限 设置最大 5 轮,强制兜底回答
Token 爆炸 记忆池 >4k 自动摘要丢弃最早轮

⑨ 本章完结:Java 工程师 AI 全栈成长路线图(大复盘)

章节 技能 你现在会
Ch4 手写 CNN 纯 Java 卷积池化,98 % MNIST
Ch5 手写 LSTM 双向 + Attention,87 % 影评
Ch6 手写 Transformer Multi-Head + PosEnc,续写对联
Ch7 大模型剖析 175B 参数、ZeRO、RLHF 成本
Ch8 提示工程 Zero/Few/CoT/Role,Token 计费
Ch9 RAG 开卷 ES 向量召回,幻觉↓70 %,Token↓50 %
Ch10 Agent 骨架 反射调工具,循环计划-执行-观察

Similar Posts

上一篇 AI-概念扫盲

Comments