Part B(第二部分)

实验4是项目2B的先决条件,强烈建议学习第6-9讲、讨论3-4和作业2-3。请确保遵循调用约定!详见调用约定附录。

在这一部分中,您将实现矩阵乘法函数和文件操作来读取手写数字图片。然后您将使用上一部分的数学函数来确定图片中的数字。

如果您对机器学习算法的工作原理感到好奇,可以展开下面的神经网络部分。这是可选的,完成项目不需要了解。

可选:神经网络

基本上,神经网络试图近似一个(非线性)函数,将您的输入映射到所需的输出。一个基本的神经元由输入的加权线性组合后跟一个非线性操作组成——例如,阈值。考虑以下实现逻辑AND操作的神经元:

' style="background-blend-mode:normal!important;background-clip:content-box!important;background-position:50% 50%!important;background-color:rgba(0,0,0,0)!important;background-image:var(--sf-img-2)!important;background-size:100% 100%!important;background-origin:content-box!important;background-repeat:no-repeat!important">

很容易看出,当A=0, B=0时,线性组合0*0.6 + 0*0.6 = 0,小于阈值1,输出为0。当输入A=0, B=1或A=1, B=0时,线性组合为1*0.6 + 0*0.6 = 0.6,小于1,输出为0。类似地,A=1, B=1时结果为1*0.6+1*0.6=1.2,大于阈值,输出为1!有趣的是,简单的神经元操作也可以描述为向量[A,B]^T和权重向量[0.6,0.6]^T之间的内积,后跟阈值非线性操作。

更复杂的函数不能仅用单个简单神经元来描述。我们可以将系统扩展为神经元网络,以近似复杂的函数。例如,以下2层网络近似逻辑函数XOR

' style="background-blend-mode:normal!important;background-clip:content-box!important;background-position:50% 50%!important;background-color:rgba(0,0,0,0)!important;background-image:var(--sf-img-3)!important;background-size:100% 100%!important;background-origin:content-box!important;background-repeat:no-repeat!important">

以上是一个2层网络。网络接收2个输入,计算2个中间值,最后计算一个最终输出。

它可以写成矩阵m_0m_1的矩阵乘法,中间有阈值操作,如下所示:

' style="background-blend-mode:normal!important;background-clip:content-box!important;background-position:50% 50%!important;background-color:rgba(0,0,0,0)!important;background-image:var(--sf-img-4)!important;background-size:100% 100%!important;background-origin:content-box!important;background-repeat:no-repeat!important">

请自行验证这在适当输入下实现了XOR

您可能想知道网络的权重是如何确定的?这超出了本项目的范围,我们鼓励您学习数值线性代数、信号处理、机器学习和优化等高级课程。我们只能说,权重可以通过给网络提供正确的输入输出对并调整权重来训练,使网络输出与正确输出之间的误差最小化。学习权重称为"训练"。在输入上使用权重称为"推理"。我们只进行推理,您将获得由TA预先训练好的权重。

在本项目中,我们将实现一个类似但稍微复杂的网络,能够分类手写数字。作为输入,我们将使用MNIST数据集,这是一个包含60,000张28x28手写数字图像(0-9)的数据集。我们将这些图像视为大小为784(= 28 * 28)的"展平"输入向量。与前面的示例类似,我们将使用预训练的权重矩阵m_0m_1进行矩阵乘法。我们将使用两种不同的非线性函数代替阈值:ReLUArgMax函数。详细信息将在各个任务的描述中提供。

' style="background-blend-mode:normal!important;background-clip:content-box!important;background-position:50% 50%!important;background-color:rgba(0,0,0,0)!important;background-image:var(--sf-img-5)!important;background-size:100% 100%!important;background-origin:content-box!important;background-repeat:no-repeat!important">

任务6:矩阵乘法

在任务6到9中,您将实现矩阵乘法函数和文件操作来读取手写数字图片。然后您将使用上一部分的数学函数来确定图片中的数字。

如果您对机器学习算法的工作原理感到好奇,可以展开下面的神经网络部分。这是可选的,完成项目不需要了解。

可选:神经网络

基本上,神经网络试图近似一个(非线性)函数,将您的输入映射到所需的输出。一个基本的神经元由输入的加权线性组合后跟一个非线性操作组成——例如,阈值。考虑以下实现逻辑AND操作的神经元:

' style="background-blend-mode:normal!important;background-clip:content-box!important;background-position:50% 50%!important;background-color:rgba(0,0,0,0)!important;background-image:var(--sf-img-2)!important;background-size:100% 100%!important;background-origin:content-box!important;background-repeat:no-repeat!important">

很容易看出,当A=0, B=0时,线性组合0*0.6 + 0*0.6 = 0,小于阈值1,输出为0。当输入A=0, B=1或A=1, B=0时,线性组合为1*0.6 + 0*0.6 = 0.6,小于1,输出为0。类似地,A=1, B=1时结果为1*0.6+1*0.6=1.2,大于阈值,输出为1!有趣的是,简单的神经元操作也可以描述为向量[A,B]^T和权重向量[0.6,0.6]^T之间的内积,后跟阈值非线性操作。

更复杂的函数不能仅用单个简单神经元来描述。我们可以将系统扩展为神经元网络,以近似复杂的函数。例如,以下2层网络近似逻辑函数XOR

' style="background-blend-mode:normal!important;background-clip:content-box!important;background-position:50% 50%!important;background-color:rgba(0,0,0,0)!important;background-image:var(--sf-img-3)!important;background-size:100% 100%!important;background-origin:content-box!important;background-repeat:no-repeat!important">

以上是一个2层网络。网络接收2个输入,计算2个中间值,最后计算一个最终输出。

它可以写成矩阵m_0m_1的矩阵乘法,中间有阈值操作,如下所示:

' style="background-blend-mode:normal!important;background-clip:content-box!important;background-position:50% 50%!important;background-color:rgba(0,0,0,0)!important;background-image:var(--sf-img-4)!important;background-size:100% 100%!important;background-origin:content-box!important;background-repeat:no-repeat!important">

请自行验证这在适当输入下实现了XOR

您可能想知道网络的权重是如何确定的?这超出了本项目的范围,我们鼓励您学习数值线性代数、信号处理、机器学习和优化等高级课程。我们只能说,权重可以通过给网络提供正确的输入输出对并调整权重来训练,使网络输出与正确输出之间的误差最小化。学习权重称为"训练"。在输入上使用权重称为"推理"。我们只进行推理,您将获得由TA预先训练好的权重。

在本项目中,我们将实现一个类似但稍微复杂的网络,能够分类手写数字。作为输入,我们将使用MNIST数据集,这是一个包含60,000张28x28手写数字图像(0-9)的数据集。我们将这些图像视为大小为784(= 28 * 28)的"展平"输入向量。与前面的示例类似,我们将使用预训练的权重矩阵m_0m_1进行矩阵乘法。我们将使用两种不同的非线性函数代替阈值:ReLUArgMax函数。详细信息将在各个任务的描述中提供。

' style="background-blend-mode:normal!important;background-clip:content-box!important;background-position:50% 50%!important;background-color:rgba(0,0,0,0)!important;background-image:var(--sf-img-5)!important;background-size:100% 100%!important;background-origin:content-box!important;background-repeat:no-repeat!important">

概念概述:矩阵存储

矩阵是一个二维整数数组。在本项目中,矩阵将以行主序存储为整数数组。行主序意味着我们将矩阵的每一行作为一维整数数组连续存储在内存中。

概念概述:矩阵乘法

矩阵乘法函数接收两个整数矩阵A(维度n × m)和B(维度m × k),输出整数矩阵C(维度n × k)。

要计算C的第i行第j列的元素,取A的第i行和B的第j列的点积。注意,这可以通过使用适当的步长调用dot函数来完成。

例如,在上图中,我们通过取A的第1行和B的第1行的点积来计算C的第1行第1列的元素。

在上图中,我们正在计算C的第2行第2列的元素。请注意,我们正在更改数组起始指针以访问后面的行和列。

你的任务

src/matmul.s中填写matmul函数。

matmul: Task 6.
参数 a0 int * 指向第一个矩阵A开头的指针(以行主序存储为整数数组)。
a1 int 第一个矩阵A的行数(高度)。
a2 int 第一个矩阵A的列数(宽度)。
a3 int * 指向第二个矩阵B开头的指针(以行主序存储为整数数组)。
a4 int 第二个矩阵B的行数(高度)。
a5 int 第二个矩阵B的列数(宽度)。
a6 int * 指向用于存储结果C的整数数组开头的指针。您可以假设此内存已分配(但未初始化)且有足够空间存储C。
返回值

如果输入在以下方面格式不正确,请将适当的返回代码放入a0并运行j exit退出程序。

返回代码 异常
38 任一矩阵的高度或宽度小于1。
38 第一个矩阵A的列数(宽度)不等于第二个矩阵B的行数(高度)。

测试与调试

要测试您的函数,在本地终端中运行bash test.sh test_matmul

要调试您的函数,在Venus终端中运行cd /vmfs/test-src,然后运行VDB命令启动调试器:

vdb test_matmul_length_1.s
vdb test_matmul_negative_dim_m0_x.s
vdb test_matmul_negative_dim_m0_y.s
vdb test_matmul_negative_dim_m1_x.s
vdb test_matmul_negative_dim_m1_y.s
vdb test_matmul_nonsquare_1.s
vdb test_matmul_nonsquare_2.s
vdb test_matmul_square.s
vdb test_matmul_unmatched_dims.s
vdb test_matmul_zero_dim_m0.s
vdb test_matmul_zero_dim_m1.s

注意:错误情况不会在Venus上报错,只会在通过bash test.sh运行测试时报错。

调试建议:

  • 由于您需要在matmul中调用dot函数,请确保遵循调用约定!详见调用约定附录。特别是,一旦您调用dotdot函数可以修改所有t0-t6和a1-a7寄存器,因此当dot函数返回时,您需要假设这些寄存器包含垃圾值
  • 您可以使用调用约定附录中描述的函数来调试调用约定错误。
  • 观看调试视频

任务7:读取矩阵

概念概述:矩阵文件

请记住任务6中提到的,矩阵以行主序作为整数数组存储在内存中。

矩阵在文件中存储为连续的4字节整数序列。文件中的第一个和第二个整数分别表示矩阵的行数和列数。其余整数以行主序存储矩阵中的元素。

所有矩阵文件都以.bin文件扩展名结尾,位于tests文件夹中。要查看矩阵文件,您可以运行xxd -e matrix_file.bin,将matrix_file.bin替换为您要读取的矩阵文件。

读取矩阵文件

在您的本地终端(不是Venus终端)中,导航到tests文件夹(例如cd tests),然后导航到包含您要读取的文件的文件夹。在此示例中,我们将cd read-matrix-1来检查第一个测试。

运行ls查看此目录中的文件。应该有一个文件input.bin。运行xxd -e input.bin查看此文件的内容。输出应该类似于:

00000000: 00000003 00000003 00000001 00000002  ................
00000010: 00000003 00000004 00000005 00000006  ................
00000020: 00000007 00000008 00000009           ............

最左边的列索引文件中的字节(例如,第三行从文件的第0x20个字节开始)。右边的点将文件中的字节显示为ASCII,但这些字节不对应可打印的ASCII字符,所以只显示点占位符。

文件的实际内容以4字节块列出,每行4个。第一行有数字3(行数)、3(列数)、1(第一个元素)和2(第二个元素)。这是一个3x3矩阵,元素为[1, 2, 3, 4, 5, 6, 7, 8, 9]。

你的任务

src/read_matrix.s中填写read_matrix函数。此函数应执行以下操作:

  1. 以读权限打开文件。文件路径作为参数(a0)提供。
  2. 从文件中读取行数和列数(记住:这些是文件中的前两个整数)。将这些整数存储在提供的指针指向的内存中(a1为行数,a2为列数)。
  3. 在堆上分配空间来存储矩阵。(提示:使用上一步的行数和列数来确定要分配多少空间。)
  4. 从文件中读取矩阵到上一步分配的内存中。
  5. 关闭文件。
  6. 返回指向内存中矩阵的指针。
read_matrix: Task 7.
参数 a0 char * 指向文件名字符串的指针。
a1 int * 指向将包含行数的整数的指针。您可以假设这指向已分配的内存。
a2 int * 指向将包含列数的整数的指针。您可以假设这指向已分配的内存。
返回值 a0 int * 指向内存中矩阵的指针。

如果输入在以下方面格式不正确,请将适当的返回代码放入a0并运行j exit退出程序。

返回代码 异常
26 malloc返回错误。
27 fopen返回错误。
28 fclose返回错误。
29 fread未读取正确的字节数。

要实现此函数,您需要调用一些实用函数。完整的函数定义集可以在附录中找到。此任务的相关函数定义如下(展开部分查看)。

任务7:相关函数定义
  1. 以读权限打开文件。文件路径作为参数(a0)提供。
fopen:打开文件进行读取或写入。
参数 a0 char * 指向文件名字符串的指针。
a1 int 权限位。0为只读,1为只写。
返回值 a0 int 文件描述符。此整数可用于其他文件操作函数来引用打开的文件。如果打开文件失败,此值为-1。
  1. 从文件中读取行数和列数(记住:这些是文件中的前两个整数)。将这些整数存储在提供的指针指向的内存中(a1为行数,a2为列数)。
fread:从文件读取字节到内存中的缓冲区。后续读取将从文件的后面部分读取。
参数 a0 int 要读取的文件的文件描述符,之前由fopen返回。
a1 int* 指向存储读取字节的缓冲区的指针。
a2 int 要从文件中读取的字节数。
返回值 a0 int 实际从文件中读取的字节数。如果此值与a2中提供的参数不同,则要么到达文件末尾,要么出现错误。
  1. 在堆上分配空间来存储矩阵。(提示:使用上一步的行数和列数来确定要分配多少空间。)
malloc:分配堆内存。
参数 a0 int 要分配的内存大小(以字节为单位)。
返回值 a0 void * 指向已分配内存的指针。如果分配失败,此值为0。
  1. 从文件中读取矩阵到上一步分配的内存中。(使用上面的fread。)

  2. 关闭文件。

fclose:关闭文件,保存我们对文件所做的任何写入。
参数 a0 int 要关闭的文件的文件描述符,之前由fopen返回。
返回值 a0 int 成功时为0,否则为-1。
  1. 返回指向内存中矩阵的指针。

测试与调试

要测试您的函数,在本地终端中运行bash test.sh test_read_matrix

要调试您的函数,在Venus终端中运行cd /vmfs/test-src,然后运行VDB命令启动调试器:

vdb test_read_1.s
vdb test_read_2.s
vdb test_read_3.s
vdb test_read_fail_fclose.s
vdb test_read_fail_fopen.s
vdb test_read_fail_fread.s
vdb test_read_fail_malloc.s

注意:错误情况不会在Venus上报错,只会在通过bash test.sh运行测试时报错。

提醒一下,您可以使用调用约定附录中描述的函数来调试调用约定错误。我们还有调试视频可能帮助您调试这些错误。

任务8:写入矩阵

src/write_matrix.s中填写write_matrix函数。此函数应执行以下操作:

  1. 以写权限打开文件。文件路径作为参数提供。
  2. 将行数和列数写入文件。(提示:fwrite函数期望指向内存中数据的指针,因此您应该先将数据存储到内存,然后将指向数据的指针传递给fwrite。)
  3. 将数据写入文件。
  4. 关闭文件。
write_matrix: Task 8.
参数 a0 char * 指向文件名字符串的指针。
a1 int * 指向内存中矩阵的指针(存储为整数数组)。
a2 int 矩阵的行数。
a3 int 矩阵的列数。
返回值

如果输入在以下方面格式不正确,请将适当的返回代码放入a0并运行j exit退出程序。

返回代码 异常
27 fopen返回错误。
30 fwrite未写入正确的字节数。
28 fclose返回错误。

要实现此函数,您需要调用一些实用函数。完整的函数定义集可以在附录中找到。此任务的相关函数定义如下(展开部分查看)。

任务8:相关函数定义
  1. 以写权限打开文件。文件路径作为参数提供。
fopen:打开文件进行读取或写入。
参数 a0 char * 指向文件名字符串的指针。
a1 int 权限位。0为只读,1为只写。
返回值 a0 int 文件描述符。此整数可用于其他文件操作函数来引用打开的文件。如果打开文件失败,此值为-1。
  1. 将行数和列数写入文件。(提示:fwrite函数期望指向内存中数据的指针,因此您应该先将数据存储到内存,然后将指向数据的指针传递给fwrite。)

  2. 将数据写入文件。

fwrite:将内存缓冲区中的字节写入文件。后续写入追加到现有文件的末尾。
参数 a0 int 要写入的文件的文件描述符,之前由fopen返回。
a1 void * 指向包含要写入文件内容的缓冲区的指针。
a2 int 要写入文件的元素数量。
a3 int 每个元素的大小。总共写入a2 × a3字节。
返回值 a0 int 实际写入文件的项数。如果此值与指定的项数(a2)不同,则要么到达文件末尾,要么出现错误。
  1. 关闭文件。
fclose:关闭文件,保存我们对文件所做的任何写入。
参数 a0 int 要关闭的文件的文件描述符,之前由fopen返回。
返回值 a0 int 成功时为0,否则为-1。

测试与调试

要测试您的函数,在本地终端中运行bash test.sh test_write_matrix

要调试您的函数,在Venus终端中运行cd /vmfs/test-src,然后运行VDB命令启动调试器:

vdb test_write_1.s
vdb test_write_fail_fclose.s
vdb test_write_fail_fopen.s
vdb test_write_fail_fwrite.s

注意:错误情况不会在Venus上报错,只会在通过bash test.sh运行测试时报错。

提醒一下,您可以使用调用约定附录中描述的函数来调试调用约定错误。我们还有调试视频可能帮助您调试这些错误。

任务9:分类

回想我们正在尝试创建的神经网络。在此任务中,您将使用前面任务的函数来完成位于src/classify.s中的分类函数。

src/classify.s中填写classify函数。此函数应执行以下操作:

  1. 从文件中读取三个矩阵m0m1input。它们的文件路径作为参数提供。您需要为read_matrix的指针参数分配空间,因为该函数期望指向已分配内存的指针。
  2. 计算h = matmul(m0, input)。您可能需要malloc空间来容纳h
  3. 计算h = relu(h)。请记住relu是原地操作。
  4. 计算o = matmul(m1, h)并将结果矩阵写入output文件。output文件路径作为参数提供。
  5. 计算并返回argmax(o)。如果print参数设为0,则还要打印argmax(o)和一个换行符。
  6. 释放您用malloc分配的所有数据。这包括调用read_matrix时分配的堆块。
  7. 请记住在返回之前将返回值argmax(o)放入适当的寄存器。
classify: Task 9.
参数 a0 int argc(提供的参数数量)
a1 char ** argv,指向参数字符串数组(char *)的指针
a1[1] = *(a1 + 4) char * 指向第一个矩阵文件m0的文件路径字符串的指针。
a1[2] = *(a1 + 8) char * 指向第二个矩阵文件m1的文件路径字符串的指针。
a1[3] = *(a1 + 12) char * 指向输入矩阵文件input的文件路径字符串的指针。
a1[4] = *(a1 + 16) char * 指向输出文件的文件路径字符串的指针。
a2 int 如果设为0,则打印分类结果。否则,不打印任何内容。
返回值 a0 int 分类结果(见上文)。

如果输入在以下方面格式不正确,请将适当的返回代码放入a0并运行j exit退出程序。

返回代码 异常
26 malloc返回错误。
31 命令行参数数量不正确。请注意程序有5个参数,因为a1[0]保留给程序名称。

要实现此函数,您需要调用一些实用函数。完整的函数定义集可以在附录中找到。此任务的相关函数定义如下(展开部分查看)。

任务9:相关函数定义
  1. 从文件中读取三个矩阵m0m1input。它们的文件路径作为参数提供。您需要为read_matrix的指针参数分配空间,因为该函数期望指向已分配内存的指针。
read_matrix: Task 7.
参数 a0 char * 指向文件名字符串的指针。
a1 int * 指向将包含行数的整数的指针。您可以假设这指向已分配的内存。
a2 int * 指向将包含列数的整数的指针。您可以假设这指向已分配的内存。
返回值 a0 int * 指向内存中矩阵的指针。
  1. 计算h = matmul(m0, input)。您可能需要malloc空间来容纳h
malloc:分配堆内存。
参数 a0 int 要分配的内存大小(以字节为单位)。
返回值 a0 void * 指向已分配内存的指针。如果分配失败,此值为0。
matmul: Task 6.
参数 a0 int * 指向第一个矩阵A开头的指针(以行主序存储为整数数组)。
a1 int 第一个矩阵A的行数(高度)。
a2 int 第一个矩阵A的列数(宽度)。
a3 int * 指向第二个矩阵B开头的指针(以行主序存储为整数数组)。
a4 int 第二个矩阵B的行数(高度)。
a5 int 第二个矩阵B的列数(宽度)。
a6 int * 指向用于存储结果C的整数数组开头的指针。您可以假设此内存已分配(但未初始化)且有足够空间存储C。
返回值
  1. 计算h = relu(h)。请记住relu是原地操作。
relu: Task 2.
参数 a0 int * 指向整数数组开头的指针。
a1 int 数组中整数的数量。您可以假设此参数与整数数组的实际长度匹配。
返回值
  1. 计算o = matmul(m1, h)并将结果矩阵写入output文件。output文件路径作为参数提供。
write_matrix: Task 8.
参数 a0 char * 指向文件名字符串的指针。
a1 int * 指向内存中矩阵的指针(存储为整数数组)。
a2 int 矩阵的行数。
a3 int 矩阵的列数。
返回值
  1. 计算并返回argmax(o)。如果print参数设为0,则还要打印argmax(o)和一个换行符。
argmax: Task 3.
参数 a0 int * 指向整数数组开头的指针。
a1 int 数组中整数的数量。您可以假设此参数与整数数组的实际长度匹配。
返回值 a0 int 最大元素的索引。如果最大元素出现多次,返回最小的索引。
print_int:打印整数。
参数 a0 int 要打印的整数。
返回值
print_char:打印字符。
参数 a0 char 要打印的字符。您可以提供ASCII码或直接将字符放入寄存器,如li t0 '\n'
返回值
  1. 释放您用malloc分配的所有数据。这包括调用read_matrix时分配的堆块。
free:释放堆内存。
参数 a0 void * 指向要释放的已分配内存的指针。
返回值
  1. 请记住在返回之前将返回值argmax(o)放入适当的寄存器。

测试与调试

要测试您的函数,在本地终端中运行bash test.sh test_classify

要调试您的函数,在Venus终端中运行cd /vmfs/test-src,然后运行VDB命令启动调试器:

vdb test_classify_1_silent.s ../tests/classify-1/m0.bin ../tests/classify-1/m1.bin ../tests/classify-1/input.bin ../tests/classify-1/output.bin
vdb test_classify_2_print.s ../tests/classify-2/m0.bin ../tests/classify-2/m1.bin ../tests/classify-2/input.bin ../tests/classify-2/output.bin
vdb test_classify_3_print.s ../tests/classify-3/m0.bin ../tests/classify-3/m1.bin ../tests/classify-3/input.bin ../tests/classify-3/output.bin
vdb test_classify_fail_malloc.s ../tests/classify-1/m0.bin ../tests/classify-1/m1.bin ../tests/classify-1/input.bin ../tests/classify-1/output.bin
vdb test_classify_not_enough_args.s

注意:错误情况不会在Venus上报错,只会在通过bash test.sh运行测试时报错。

一旦classify运行成功,您可以运行bash test.sh test_chain。这会运行您的分类函数两次,以确保您正确遵循了调用约定。

提醒一下,您可以使用调用约定附录中描述的函数来调试调用约定错误。我们还有调试视频可能帮助您调试这些错误。

要调试链式测试,运行cd /vmfs/test-src,然后运行VDB命令启动调试器:

vdb ../tests/chain-1/chain.s

任务10:合作伙伴/反馈表

恭喜您完成项目!我们很希望听到您对未来学期改进的反馈。

请填写此简短表单,您可以在其中提供对项目的想法和(如果适用)您的合作情况。您提供的任何反馈都不会影响您的成绩,请放心坦诚和建设性地表达。

提交与评分

将您的代码提交到Gradescope上的Project 2B作业。

为确保自动评分器正确运行,请不要在起始代码中添加任何.import语句。同时,请确保您的代码中没有ecall指令。