每日算法 — 使用java实现CNN:卷积神经网络与图像特征提取

引言

卷积神经网络(CNN)是深度学习处理网格结构数据(尤其是图像)的核心架构。本文用纯Java实现一个简化版CNN,包含卷积层、ReLU激活、最大池化与全连接层,从零构建前向传播与反向传播,帮助读者理解参数共享、局部连接与特征层次化提取的本质。

一、核心组件设计

1.1 张量工具类

public class Tensor {
    public final int depth, height, width;
    public final double[][][] data;

    public Tensor(int depth, int height, int width) {
        this.depth = depth;
        this.height = height;
        this.width = width;
        this.data = new double[depth][height][width];
    }

    public Tensor(double[][][] data) {
        this.depth = data.length;
        this.height = data[0].length;
        this.width = data[0][0].length;
        this.data = deepCopy(data);
    }

    private static double[][][] deepCopy(double[][][] src) {
        double[][][] dst = new double[src.length][][];
        for (int i = 0; i < src.length; i++) {
            dst[i] = new double[src[i].length][];
            for (int j = 0; j < src[i].length; j++) {
                dst[i][j] = src[i][j].clone();
            }
        }
        return dst;
    }

    /**
     * 填充零边界
     */
    public Tensor pad(int padH, int padW) {
        Tensor result = new Tensor(depth, height + 2 * padH, width + 2 * padW);
        for (int d = 0; d < depth; d++) {
            for (int h = 0; h < height; h++) {
                System.arraycopy(data[d][h], 0, result.data[d][h + padH], padW, width);
            }
        }
        return result;
    }
}

二、卷积层实现

2.1 卷积运算

卷积核在输入上滑动,局部加权求和:

public class ConvolutionLayer {
    public final int numFilters;      // 卷积核数量(输出通道数)
    public final int filterSize;      // 卷积核尺寸(正方形)
    public final int stride;          // 步长
    public final int padding;         // 零填充大小

    // 参数: [输出通道][输入通道][核高][核宽]
    public final double[][][][] filters;
    public final double[] biases;

    // 缓存用于反向传播
    private Tensor inputCache;
    private Tensor outputCache;

    public ConvolutionLayer(int numFilters, int filterSize, 
                            int stride, int padding, int inputDepth) {
        this.numFilters = numFilters;
        this.filterSize = filterSize;
        this.stride = stride;
        this.padding = padding;

        // Xavier初始化
        double scale = Math.sqrt(2.0 / (inputDepth * filterSize * filterSize));
        this.filters = new double[numFilters][inputDepth][filterSize][filterSize];
        for (int f = 0; f < numFilters; f++) {
            for (int d = 0; d < inputDepth; d++) {
                for (int i = 0; i < filterSize; i++) {
                    for (int j = 0; j < filterSize; j++) {
                        filters[f][d][i][j] = (Math.random() - 0.5) * 2 * scale;
                    }
                }
            }
        }
        this.biases = new double[numFilters];
    }

    /**
     * 前向传播: 对输入执行卷积
     */
    public Tensor forward(Tensor input) {
        this.inputCache = input;
        Tensor padded = input.pad(padding, padding);

        int outH = (input.height + 2 * padding - filterSize) / stride + 1;
        int outW = (input.width + 2 * padding - filterSize) / stride + 1;
        Tensor output = new Tensor(numFilters, outH, outW);

        for (int f = 0; f < numFilters; f++) {
            for (int oh = 0; oh < outH; oh++) {
                for (int ow = 0; ow < outW; ow++) {
                    double sum = biases[f];
                    int ihStart = oh * stride;
                    int iwStart = ow * stride;

                    for (int d = 0; d < input.depth; d++) {
                        for (int fh = 0; fh < filterSize; fh++) {
                            for (int fw = 0; fw < filterSize; fw++) {
                                sum += padded.data[d][ihStart + fh][iwStart + fw] 
                                     * filters[f][d][fh][fw];
                            }
                        }
                    }
                    output.data[f][oh][ow] = sum;
                }
            }
        }

        this.outputCache = output;
        return output;
    }
}

2.2 反向传播

public class ConvolutionLayer {

    /**
     * 反向传播计算梯度
     * @param gradOutput 损失对输出的梯度 [numFilters][outH][outW]
     * @return 损失对输入的梯度
     */
    public Tensor backward(Tensor gradOutput) {
        Tensor paddedInput = inputCache.pad(padding, padding);
        Tensor gradInputPadded = new Tensor(paddedInput.depth, 
                                            paddedInput.height, paddedInput.width);

        // 计算filter梯度和bias梯度
        double[][][][] gradFilters = new double[numFilters][inputCache.depth]
                                                     [filterSize][filterSize];
        double[] gradBiases = new double[numFilters];

        for (int f = 0; f < numFilters; f++) {
            for (int oh = 0; oh < gradOutput.height; oh++) {
                for (int ow = 0; ow < gradOutput.width; ow++) {
                    double grad = gradOutput.data[f][oh][ow];
                    gradBiases[f] += grad;

                    int ihStart = oh * stride;
                    int iwStart = ow * stride;

                    for (int d = 0; d < inputCache.depth; d++) {
                        for (int fh = 0; fh < filterSize; fh++) {
                            for (int fw = 0; fw < filterSize; fw++) {
                                gradFilters[f][d][fh][fw] += 
                                    paddedInput.data[d][ihStart + fh][iwStart + fw] * grad;
                                gradInputPadded.data[d][ihStart + fh][iwStart + fw] += 
                                    filters[f][d][fh][fw] * grad;
                            }
                        }
                    }
                }
            }
        }

        // 更新参数 (简化版SGD)
        double lr = 0.01;
        for (int f = 0; f < numFilters; f++) {
            biases[f] -= lr * gradBiases[f];
            for (int d = 0; d < inputCache.depth; d++) {
                for (int i = 0; i < filterSize; i++) {
                    for (int j = 0; j < filterSize; j++) {
                        filters[f][d][i][j] -= lr * gradFilters[f][d][i][j];
                    }
                }
            }
        }

        // 去除padding,返回对原始输入的梯度
        return unpad(gradInputPadded);
    }

    private Tensor unpad(Tensor padded) {
        Tensor result = new Tensor(padded.depth, 
                                   padded.height - 2 * padding, 
                                   padded.width - 2 * padding);
        for (int d = 0; d < result.depth; d++) {
            for (int h = 0; h < result.height; h++) {
                System.arraycopy(padded.data[d][h + padding], padding, 
                               result.data[d][h], 0, result.width);
            }
        }
        return result;
    }
}

三、激活层与池化层

3.1 ReLU激活

public class ReLULayer {
    private Tensor mask; // 记录哪些位置>0,用于反向传播

    public Tensor forward(Tensor input) {
        this.mask = new Tensor(input.depth, input.height, input.width);
        Tensor output = new Tensor(input.depth, input.height, input.width);

        for (int d = 0; d < input.depth; d++) {
            for (int h = 0; h < input.height; h++) {
                for (int w = 0; w < input.width; w++) {
                    double val = input.data[d][h][w];
                    if (val > 0) {
                        output.data[d][h][w] = val;
                        mask.data[d][h][w] = 1;
                    } else {
                        output.data[d][h][w] = 0;
                        mask.data[d][h][w] = 0;
                    }
                }
            }
        }
        return output;
    }

    public Tensor backward(Tensor gradOutput) {
        Tensor gradInput = new Tensor(gradOutput.depth, gradOutput.height, gradOutput.width);
        for (int d = 0; d < gradOutput.depth; d++) {
            for (int h = 0; h < gradOutput.height; h++) {
                for (int w = 0; w < gradOutput.width; w++) {
                    gradInput.data[d][h][w] = gradOutput.data[d][h][w] * mask.data[d][h][w];
                }
            }
        }
        return gradInput;
    }
}

3.2 最大池化层

public class MaxPoolLayer {
    private final int poolSize;
    private final int stride;
    private int[][][] maxIndicesH; // 记录每个输出对应的最大值位置H
    private int[][][] maxIndicesW; // 记录每个输出对应的最大值位置W

    public MaxPoolLayer(int poolSize, int stride) {
        this.poolSize = poolSize;
        this.stride = stride;
    }

    public Tensor forward(Tensor input) {
        int outH = (input.height - poolSize) / stride + 1;
        int outW = (input.width - poolSize) / stride + 1;
        Tensor output = new Tensor(input.depth, outH, outW);

        maxIndicesH = new int[input.depth][outH][outW];
        maxIndicesW = new int[input.depth][outH][outW];

        for (int d = 0; d < input.depth; d++) {
            for (int oh = 0; oh < outH; oh++) {
                for (int ow = 0; ow < outW; ow++) {
                    double maxVal = Double.NEGATIVE_INFINITY;
                    int maxH = 0, maxW = 0;
                    int ihStart = oh * stride;
                    int iwStart = ow * stride;

                    for (int ph = 0; ph < poolSize; ph++) {
                        for (int pw = 0; pw < poolSize; pw++) {
                            double val = input.data[d][ihStart + ph][iwStart + pw];
                            if (val > maxVal) {
                                maxVal = val;
                                maxH = ihStart + ph;
                                maxW = iwStart + pw;
                            }
                        }
                    }

                    output.data[d][oh][ow] = maxVal;
                    maxIndicesH[d][oh][ow] = maxH;
                    maxIndicesW[d][oh][ow] = maxW;
                }
            }
        }
        return output;
    }

    public Tensor backward(Tensor gradOutput, int inH, int inW) {
        Tensor gradInput = new Tensor(gradOutput.depth, inH, inW);

        for (int d = 0; d < gradOutput.depth; d++) {
            for (int oh = 0; oh < gradOutput.height; oh++) {
                for (int ow = 0; ow < gradOutput.width; ow++) {
                    int mh = maxIndicesH[d][oh][ow];
                    int mw = maxIndicesW[d][oh][ow];
                    gradInput.data[d][mh][mw] += gradOutput.data[d][oh][ow];
                }
            }
        }
        return gradInput;
    }
}

四、全连接层与Softmax

4.1 全连接层

public class FullyConnectedLayer {
    public final int inputSize, outputSize;
    public final double[][] weights;
    public final double[] biases;

    private double[] inputCache;
    private double[] outputCache;

    public FullyConnectedLayer(int inputSize, int outputSize) {
        this.inputSize = inputSize;
        this.outputSize = outputSize;

        double scale = Math.sqrt(2.0 / inputSize);
        this.weights = new double[outputSize][inputSize];
        for (int i = 0; i < outputSize; i++) {
            for (int j = 0; j < inputSize; j++) {
                weights[i][j] = (Math.random() - 0.5) * 2 * scale;
            }
        }
        this.biases = new double[outputSize];
    }

    public double[] forward(double[] input) {
        this.inputCache = input.clone();
        double[] output = new double[outputSize];

        for (int i = 0; i < outputSize; i++) {
            double sum = biases[i];
            for (int j = 0; j < inputSize; j++) {
                sum += weights[i][j] * input[j];
            }
            output[i] = sum;
        }

        this.outputCache = output.clone();
        return output;
    }

    public double[] backward(double[] gradOutput, double lr) {
        double[] gradInput = new double[inputSize];

        for (int i = 0; i < outputSize; i++) {
            biases[i] -= lr * gradOutput[i];
            for (int j = 0; j < inputSize; j++) {
                weights[i][j] -= lr * gradOutput[i] * inputCache[j];
                gradInput[j] += gradOutput[i] * weights[i][j];
            }
        }
        return gradInput;
    }
}

4.2 Softmax与交叉熵损失

public class SoftmaxCrossEntropy {
    private double[] probs; // 缓存softmax概率

    /**
     * Softmax前向 + 交叉熵损失计算
     * @param logits 全连接层输出
     * @param label  真实类别索引
     * @return 损失值
     */
    public double forward(double[] logits, int label) {
        // Softmax
        double maxLogit = Double.NEGATIVE_INFINITY;
        for (double v : logits) maxLogit = Math.max(maxLogit, v);

        double sumExp = 0;
        probs = new double[logits.length];
        for (int i = 0; i < logits.length; i++) {
            probs[i] = Math.exp(logits[i] - maxLogit); // 数值稳定性
            sumExp += probs[i];
        }
        for (int i = 0; i < probs.length; i++) {
            probs[i] /= sumExp;
        }

        // 交叉熵: -log(prob[label])
        return -Math.log(probs[label] + 1e-10);
    }

    /**
     * 反向传播: 返回对logits的梯度
     */
    public double[] backward(int label) {
        double[] grad = probs.clone();
        grad[label] -= 1.0; // one-hot差分
        return grad;
    }
}

五、网络组装与训练

public class SimpleCNN {
    private final ConvolutionLayer conv1;
    private final ReLULayer relu1;
    private final MaxPoolLayer pool1;
    private final FullyConnectedLayer fc;
    private final SoftmaxCrossEntropy lossLayer;

    // 假设输入: 1x28x28 (单通道28x28图像,如MNIST)
    // conv1: 8个3x3卷积核 -> 8x28x28 (padding=1)
    // pool1: 2x2最大池化 -> 8x14x14
    // flatten: 8*14*14 = 1568
    // fc: 1568 -> 10 (10分类)

    public SimpleCNN() {
        conv1 = new ConvolutionLayer(8, 3, 1, 1, 1);
        relu1 = new ReLULayer();
        pool1 = new MaxPoolLayer(2, 2);
        fc = new FullyConnectedLayer(8 * 14 * 14, 10);
        lossLayer = new SoftmaxCrossEntropy();
    }

    /**
     * 前向传播
     */
    public double forward(Tensor input, int label) {
        Tensor t1 = conv1.forward(input);
        Tensor t2 = relu1.forward(t1);
        Tensor t3 = pool1.forward(t2);

        // Flatten
        double[] flat = flatten(t3);
        double[] logits = fc.forward(flat);

        return lossLayer.forward(logits, label);
    }

    /**
     * 反向传播
     */
    public void backward() {
        double[] gradLogits = lossLayer.backward(-1); // label已在forward时确定,这里简化

        double[] gradFlat = fc.backward(gradLogits, 0.01);
        Tensor gradPool = unflatten(gradFlat, 8, 14, 14);
        Tensor gradRelu = pool1.backward(gradPool, 28, 28);
        Tensor gradConv = relu1.backward(gradRelu);
        conv1.backward(gradConv);
    }

    private double[] flatten(Tensor t) {
        double[] result = new double[t.depth * t.height * t.width];
        int idx = 0;
        for (int d = 0; d < t.depth; d++) {
            for (int h = 0; h < t.height; h++) {
                for (int w = 0; w < t.width; w++) {
                    result[idx++] = t.data[d][h][w];
                }
            }
        }
        return result;
    }

    private Tensor unflatten(double[] flat, int d, int h, int w) {
        Tensor t = new Tensor(d, h, w);
        int idx = 0;
        for (int i = 0; i < d; i++) {
            for (int j = 0; j < h; j++) {
                for (int k = 0; k < w; k++) {
                    t.data[i][j][k] = flat[idx++];
                }
            }
        }
        return t;
    }
}

六、演示:特征可视化

public class CNNDemo {

    public static void main(String[] args) {
        // 构造一个简单输入: 边缘检测特征
        double[][][] input = new double[1][8][8];
        // 左半部分为0,右半部分为1,形成垂直边缘
        for (int h = 0; h < 8; h++) {
            for (int w = 0; w < 8; w++) {
                input[0][h][w] = w < 4 ? 0.0 : 1.0;
            }
        }

        Tensor inputTensor = new Tensor(input);

        // 创建一个手动设定的边缘检测卷积核
        ConvolutionLayer edgeDetector = new ConvolutionLayer(1, 3, 1, 1, 1);
        // Sobel垂直边缘核近似: [-1, 0, 1; -2, 0, 2; -1, 0, 1]
        edgeDetector.filters[0][0][0][0] = -1; edgeDetector.filters[0][0][0][1] = 0; edgeDetector.filters[0][0][0][2] = 1;
        edgeDetector.filters[0][0][1][0] = -2; edgeDetector.filters[0][0][1][1] = 0; edgeDetector.filters[0][0][1][2] = 2;
        edgeDetector.filters[0][0][2][0] = -1; edgeDetector.filters[0][0][2][1] = 0; edgeDetector.filters[0][0][2][2] = 1;
        edgeDetector.biases[0] = 0;

        Tensor convOut = edgeDetector.forward(inputTensor);
        System.out.println("卷积输出 (边缘响应):");
        printTensor(convOut);

        // ReLU激活
        ReLULayer relu = new ReLULayer();
        Tensor reluOut = relu.forward(convOut);
        System.out.println("\nReLU后 (抑制负响应):");
        printTensor(reluOut);

        // 最大池化
        MaxPoolLayer pool = new MaxPoolLayer(2, 2);
        Tensor poolOut = pool.forward(reluOut);
        System.out.println("\n池化后 (降采样):");
        printTensor(poolOut);
    }

    private static void printTensor(Tensor t) {
        for (int h = 0; h < t.height; h++) {
            for (int w = 0; w < t.width; w++) {
                System.out.printf("%6.2f ", t.data[0][h][w]);
            }
            System.out.println();
        }
    }
}

七、复杂度分析

层 参数量 前向时间 反向时间 说明
卷积层 F×D×K² + F O(F×D×K²×H×W) O(F×D×K²×H×W) F=输出通道,D=输入通道,K=核尺寸
ReLU 0 O(D×H×W) O(D×H×W) 逐元素操作
最大池化 0 O(D×H×W×P²) O(D×H×W) P=池化尺寸
全连接 O×I + O O(O×I) O(O×I) O=输出,I=输入
Softmax 0 O(C) O(C) C=类别数

关键优势: 卷积层通过参数共享(同一卷积核在整幅图滑动)将参数量从O(F×D×K²×H×W)降至O(F×D×K²),大幅减少过拟合风险。

八、扩展方向

  1. 批量归一化(BatchNorm): 在卷积后添加,加速收敛
  2. Dropout: 全连接层随机失活,增强泛化
  3. 残差连接(ResNet): 跳跃连接缓解梯度消失
  4. Adam优化器: 替代SGD,自适应学习率

总结

本文用Java从零实现了CNN的核心组件:卷积层、ReLU激活、最大池化、全连接与Softmax交叉熵损失。读者通过代码可以理解参数共享机制、反向传播中梯度的链式流转,以及卷积核作为特征检测器的本质。建议进一步阅读Batch Normalization与ResNet等现代架构,将基础CNN扩展为深度网络。