<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>楚权的世界</title>
  
  <subtitle>Seek the wonder of life.</subtitle>
  <link href="http://chuquan.me/atom.xml" rel="self"/>
  
  <link href="http://chuquan.me/"/>
  <updated>2025-06-08T04:20:28.495Z</updated>
  <id>http://chuquan.me/</id>
  
  <author>
    <name>Bao Chuquan</name>
    
  </author>
  
  <generator uri="https://hexo.io/">Hexo</generator>
  
  <entry>
    <title>读《技术为径》——技术组长</title>
    <link href="http://chuquan.me/2025/06/08/the-managers-path-tech-lead/"/>
    <id>http://chuquan.me/2025/06/08/the-managers-path-tech-lead/</id>
    <published>2025-06-08T04:15:12.000Z</published>
    <updated>2025-06-08T04:20:28.495Z</updated>
    
    <content type="html"><![CDATA[<p>上一篇 <ahref="http://chuquan.me/2025/06/07/the-managers-path-mentor/">读《技术为径》——技术导师</a>梳理了一下技术管理中技术导师的职责，本文则来梳理一下技术组长的相关内容。</p><span id="more"></span><h1 id="技术组长">技术组长</h1><p>技术组长，即 TechLead，其是一个基层管理岗位，主要承担一个技术研发团队的管理工作，同时要求至少投入30%的时间与团队一起交付代码，比如：移动端负责人、服务端负责人等角色。</p><p>个人职业的发展通常会有两条路径：技术专家和技术管理，比如：百度的 T序列和 M序列。对于技术管理路线，基本都会要求担任过技术组长一职，因为在更高的职级上更重要的是个人领导力和团队领导力。对于技术专家路线，虽然技术组长一职并不是晋升的必经之路，但也是较为普遍的路径。</p><h1 id="主要职责">主要职责</h1><p>技术组长的工作核心目标是<strong>推动项目按计划进行并顺利交付</strong>。为了实现这个目标，技术组长会在工作中扮演以下几种角色并承担相关职责。</p><ul><li><strong>系统分析师</strong></li><li><strong>项目计划师</strong></li><li><strong>软件工程师</strong></li><li><strong>团队沟通者</strong></li></ul><h2 id="系统分析师">系统分析师</h2><p>作为系统分析师，其工作职责是：</p><ul><li>找到哪些产品功能是项目的关键需求</li><li>分析这些需求对应需要修改哪些系统</li></ul><p>为此，技术组长需要充分了解系统的整体架构，同时需要理解如何着手设计对应的系统，这样才能够为项目的可行性、任务排序、工期时长提供可靠的依据。</p><h2 id="项目计划师">项目计划师</h2><p>作为项目计划师，其工作职责是：</p><ul><li>对整体工作合理地拆分成小的交付物</li><li>对各项任务合理地安排各自的优先级</li></ul><p>整体工作拆分的一个难点在于，如何保证团队内部可以保持并行工作、快速工作。如何协调好团队中所有人的时间是一个不小的挑战，这需要技术组长持续地投入精力。</p><p>以我个人的经验，我会优先考虑连续性工作，比如：某成员一直在维护播放器方面的工作，那么相关工作仍然会交付给他，一方面可以实现快速交付，另一方面，连贯性的工作也能让成员在此领域有更深入的研究和产出。此外，我会尽可能提前思考项目计划，比如：下一个sprint 要做 A 项目，下下个 sprint 要做 B 项目，我会根据 B项目的情况，思考如何规划 A项目的人员委派。基于多个项目思考全局最优解，避免落入局部最优解。</p><h2 id="软件工程师">软件工程师</h2><p>软件工程师的职责是比较明确的，这里会涉及到架构设计、具体编码等工作。但是，要注意，这不应该是技术组长的主要职责！技术组长的工作时间经常处于碎片化状态，应该避免其担任的编码工作出现重大的阻塞或延误。必要时，技术组长应该把相关工作委派给团队的其他成员。</p><h2 id="团队沟通者">团队沟通者</h2><p>团队沟通者的职责是作为团队的代表对外进行沟通。技术组长不应该羞于沟通，比如：当遇到困难时，尽早提出问题并不是一件坏事，反而恰恰是一件好事。高效、主动地沟通可以为团队节省很多工作时间，比如：砍掉不必要的功能、推掉不可行的需求。</p><h1 id="注意要点">注意要点</h1><p>优秀的技术组长会有很多重要的特点，以下这些是值得关注的。</p><h2 id="理解系统架构">理解系统架构</h2><p>如果自己不熟悉系统的整体架构，那么就一定要花时间尽快了解透彻，了熟于心。只有理解了系统架构，才能够理解技术与产品之间的联系。如果不太了解系统架构，是不太可能做好技术组长的角色的。</p><h2 id="维持团队氛围">维持团队氛围</h2><p>我认为维持团队氛围一个非常容易忽视，但又非常重要的一点。技术组长不应该一味追求向上管理，也要重视向下管理。这要求技术组长有比较高的情商。我认为最关键的一点是：同理心，说直白一点就是要学会换位思考。</p><p>以我个人的经验，当我在处理团队事务时，经常会将自己代入一线成员，避免作出一些令大多数成员反感的决策或事情。举一些例子：</p><ul><li>尽量不会频繁打断成员的工作节奏，不要让他们参与到低效无聊、可有可无的会议之中。</li><li>尽量不要在非工作时间打扰团队成员，比如：临近下班要求团队开会、下班后在群里发消息。</li><li>尽量不要单方面要求压缩任务排期工作。</li><li>尽量不要事无巨细地跟踪成员的工作情况，应该给予充分的自由度。</li><li>学会倾听，民主决策。</li><li>学会过滤上级的压力。当遇到线上故障或者任务压力时，上级领导可能会对技术组长施压，技术组长不应该直接转移压力，而应该对压力进行适当的过滤。</li></ul><h2 id="主导技术决策">主导技术决策</h2><p>技术组长，必然要是参与团队的技术决策过程的。这里应该避免两个极端：一种是所有决策都是完全由自己完成；另一种是自己完全不作决策，全权由团队决定。这里的难点在于技术组长要学会如何判断哪些决策由自己个人来完成，哪些决策需要授权给团队内部的技术专家来完成，亦或是哪些决策应该由团队的所有成员共同决定。</p><p>在做技术决策时，技术组长应该要学会倾听，民主决策。很多技术组长非常偏执，亦或是为了显示权威，总喜欢一意孤行、独裁断决。这种情况很容易打消团队成员的积极性，不再出谋划策。</p><h2 id="对内对外沟通">对内对外沟通</h2><p>作为技术组长，个人的工作效率和团队的工作效率相比，并不显得那么重要了。这意味着牺牲自己的时间换取团队的时间。其中最典型的就是沟通，与其让全体成员出席会议，不如由自己代表团队出席，将团队的想法传达出去，将外部的信息传递回来。</p><p>此外，技术组长还需要注意沟通的技巧和方式。对内沟通时，有些技术组长经常会以居高临下姿态和团队成员沟通，话语中尽显优越和权威。这种不平等的对话，会让沟通的另一方感到不适。长此以往，团队成员也不会愿意主动和技术组长进行任何方面的沟通。对外沟通时，有些技术组长经常会表现出莫名其妙的强势和批判，这会让外部团队感到困惑和无奈，进而也会导致进一步的合作对抗。</p><p>以我个人的经验，沟通时应该将自己摆正到平等的位置，以平和的语气进行沟通，不要试图在沟通时压倒或压迫对方，显式自己的权威。</p><h2 id="时间管理分配">时间管理分配</h2><p>大多数技术组长都会有一种感觉，每天的时间都会被各种事情所支配，当自己尝试做一件比较耗时的事情时，总是会被各种事情打断，效率极低。这时候需要技术组长必须学会管理和分配时间。尽量有计划地去做事，从自己的日程中切分出合理的、成块的时间给不同的工作。试图在每小时赶一场会议的间隔中穿插从事一些代码工作是不太现实的。</p><h1 id="总结">总结</h1><p>总而言之，技术组长会在工作中扮演多个角色：系统分析师、项目计划师、软件工程师、团队沟通者。这些角色的职责都是为了完成共同的目标，即推动项目按计划进行并顺利交付。</p><p>然而，这些只是基本要求，如果希望自己成为一名优秀的技术组长，还有很多需要关注的要点，包括：</p><ul><li>理解系统架构</li><li>维护团队氛围</li><li>主导技术决策</li><li>对内对外沟通</li><li>时间管理分配</li></ul><p>其中的每一项都有我们值得注意和学习的东西。技术组长只是我们职业发展路径的初级阶段，我们需要始终保持谦卑的态度去学习，这样才有机会有更多的发展和成长。</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;上一篇 &lt;a
href=&quot;http://chuquan.me/2025/06/07/the-managers-path-mentor/&quot;&gt;读《技术为径》——技术导师&lt;/a&gt;
梳理了一下技术管理中技术导师的职责，本文则来梳理一下技术组长的相关内容。&lt;/p&gt;</summary>
    
    
    
    <category term="读书笔记" scheme="http://chuquan.me/categories/%E8%AF%BB%E4%B9%A6%E7%AC%94%E8%AE%B0/"/>
    
    
    <category term="Tech Lead" scheme="http://chuquan.me/tags/Tech-Lead/"/>
    
    <category term="技术为径" scheme="http://chuquan.me/tags/%E6%8A%80%E6%9C%AF%E4%B8%BA%E5%BE%84/"/>
    
  </entry>
  
  <entry>
    <title>读《技术为径》——技术导师</title>
    <link href="http://chuquan.me/2025/06/07/the-managers-path-mentor/"/>
    <id>http://chuquan.me/2025/06/07/the-managers-path-mentor/</id>
    <published>2025-06-07T09:19:04.000Z</published>
    <updated>2025-06-08T04:18:10.861Z</updated>
    
    <content type="html"><![CDATA[<p>前段时间阅读了一本叫《技术为径》的书，作者是卡米尔·福涅尔，一位女性技术管理者。此书主要介绍了如何从工程师逐步进阶成为技术管理者，这里的技术管理者分为很多种层次，包括：技术导师、技术小组长、中层经理、职业经理人等。考虑到书的内容比较多，这里我分开记录一些读书笔记。本文先从技术导师开始。</p><span id="more"></span><h1 id="技术导师"><a class="markdownIt-Anchor" href="#技术导师"></a> 技术导师</h1><p>技术导师，即 Tech Mentor，其通常负责辅导校招生、实习生，在极少数情况下也负责指导社招新成员，比如：阿里巴巴的「师兄」角色。</p><h1 id="如何指导实习生"><a class="markdownIt-Anchor" href="#如何指导实习生"></a> 如何指导实习生</h1><p>对于实习生而言，一段很棒的实习经历不仅可以促使他未来加入公司，也能够提升公司作为雇主在其同学圈中的印象分。</p><p>在实习生入职之前，技术导师要准备迎接入职。对此，要着手的事情很多，比如：了解入职时间，安排入职工位、工作环境、工作设备甚至内部账号等。很多工程师其实都不太有「眼力见」，很容易忽视这些细节。事实上，对于一个新员工而言，入职第一天完全被忽视是一种非常糟糕的体验。</p><p>入职之后，指导实习生的工作应该围绕着具体的项目来进行。因此，技术导师要先确定一个难度适中的项目，如果没有合适的独立项目，也可以考虑从自己当前的工作中分离出一部分交给实习生完成。当确定了实习生的第一个项目后，技术导师应该和实习生一起对项目进行任务拆分，这样可以快速获得反馈信息，并随时回答实习生的问题。</p><p>伴随着项目的进行，技术导师应该始终注意以下三项重点：</p><ul><li>认真倾听</li><li>清晰沟通</li><li>因材施教</li></ul><p>倾听是最基础、最重要的人事管理技能，也是同理心的一种体现。无论未来身处什么职位，懂得如何倾听都极为有用。大多数人都是不善于精准表达自己的想法的，因此认真倾听意味着要透过对方的表面语言，体会其真正的意图。当面对技术导师时，实习生在心理上会处于弱势，在表达或者提问时，可能会因为胆怯或紧张，而无法准确地描述。对此，技术导师的心理应该有所准备，对于实习生提出的问题或表述，可以以自己的理解进行复述，必要时可以通过白板画图，避免沿着错误的方向前进。</p><p>沟通的重要性不言而喻。在指导实习生时，技术导师应该清晰地传达自己的期望。比如导师希望实习生在提问之前自己先进行一定的研究，那么就应该明确地跟他讲清楚。导师应该告诉实习生项目各个阶段的目标是什么？做到什么程度？学习什么知识？何时汇报进度？如果在这样清晰的指引下他还做不好，那么你也就明白了他的潜力了。</p><p>导师的第三项重点是因材施教。导师应该了解实习生各个方面的能力，从而规划对他的后续指导，加强哪方面的能力。这就需要进一步深入沟通，作者建议至少每周沟通一次；如果有额外时间，也可以聊点工作以外的内容，增进彼此之间的了解。</p><h1 id="如何指导正式员工"><a class="markdownIt-Anchor" href="#如何指导正式员工"></a> 如何指导正式员工</h1><p>指导正式员工是一项非常重要的工作，主要有以下这些目标：</p><ul><li>帮助新人尽快上手工作内容</li><li>帮助新人尽快适应工作氛围</li><li>帮助新人尽快构建人际圈子</li></ul><p>对于帮助新人上手工作内容，首先是新人文档。新人文档包含了各种工具的使用，环境的配置等。随着项目的迭代，文档内容可能与项目实际产生偏差，此时技术导师应该要求新人去更新文档。另外，也可以考虑 <strong>结对编程</strong>，一个人作为驾驶员（编写代码），一个人作为导航员，两人每隔一段时间（比如 40 分钟）互换角色。在结对编程的配合中，新人可以更加深入地理解工作的整体和细节。不过，结对编程在国内公司极其少见，但是我还是觉得可以尝试尝试。</p><p>对于帮助新人适应工作氛围，这主要涉及公司的规章制度、审批流程、工作流程、公司文化、常用术语、称呼方式等，当然还有很多小细节或者不成文的规定，这些都需要技术导师去以一个新人的视角去重新观察和理解。</p><p>对于帮助新人构建人际圈子，应该带着新人认识团队成员，可以组合各种方式和手段，比如：到各个团队成员的工位介绍两人相互认识、将新人拉进各种成员群。带新人加入自己的人际关系圈，有助于他更快地上手工作，也有助于你进入他未来的人际圈子。毕竟今天你辅导的新人，未来可能是你的引荐人。</p><p>指导过程也是一个以全新视角审视公司和团队的机会，导师应该重新思考此前习以为然的一切事务。</p><h1 id="总结"><a class="markdownIt-Anchor" href="#总结"></a> 总结</h1><p>总而言之，导师应该保持关注一下几个要点：</p><ul><li>保持好奇心，保持思想开放。辅导工作是一个以全新视角观察自己工作，以及培育自己好奇心的机会。</li><li>倾听并以顾及对方背景和立场的方式进行沟通。软件开发是一项团队活动，内部顺畅的沟通是一切成功的前提。</li><li>构建人际关系网。任何人任何行业的职业发展归根到底取决于人际关系网络。职场圈子很小，善待对方，保持良好的关系。</li></ul>]]></content>
    
    
    <summary type="html">&lt;p&gt;前段时间阅读了一本叫《技术为径》的书，作者是卡米尔·福涅尔，一位女性技术管理者。此书主要介绍了如何从工程师逐步进阶成为技术管理者，这里的技术管理者分为很多种层次，包括：技术导师、技术小组长、中层经理、职业经理人等。考虑到书的内容比较多，这里我分开记录一些读书笔记。本文先从技术导师开始。&lt;/p&gt;</summary>
    
    
    
    <category term="读书笔记" scheme="http://chuquan.me/categories/%E8%AF%BB%E4%B9%A6%E7%AC%94%E8%AE%B0/"/>
    
    
    <category term="Tech Mentor" scheme="http://chuquan.me/tags/Tech-Mentor/"/>
    
    <category term="技术为径" scheme="http://chuquan.me/tags/%E6%8A%80%E6%9C%AF%E4%B8%BA%E5%BE%84/"/>
    
  </entry>
  
  <entry>
    <title>看待问题的几种思考方式</title>
    <link href="http://chuquan.me/2025/02/09/several-ways-of-thinking/"/>
    <id>http://chuquan.me/2025/02/09/several-ways-of-thinking/</id>
    <published>2025-02-09T08:34:22.000Z</published>
    <updated>2025-02-09T08:35:22.430Z</updated>
    
    <content type="html"><![CDATA[<p>随着年龄和阅历的增长，自己看待问题的思考方式也逐步变得理性、全面，这里总结几种自己看待问题的思考方式。</p><span id="more"></span><h1 id="换位思考">换位思考</h1><p>换位思考，就是当面对一个情景或局面时，交换立场，重新思考。</p><p>换位思考特别适用于人际交往，举个例子，在处理上下级关系时，当自己想和领导抬杠时，换位思考一下，如果你是领导，遇到下属经常在公开场合和自己抬杠，你会怎么看？他在你的人才梯队中如何排序？晋升机会是否会优先考虑？</p><h1 id="递进思考">递进思考</h1><p>递进思考，就是当面对一个问题或现象时，层层递进，深入思考。</p><p>递进思考特别适用于追根溯源，《架构师应该知道的 37件事》中提到的「五问法」，本质上就是递进思考，多问几个为什么，五只不过是个泛数而已。</p><p>举个例子，如果汽车启动不了，你应该追问为什么找出根本原因：启动器无法点火，是因为电池没电；电池没电，是因为车灯一直亮着；车灯一直亮着，是因为警告车灯一直亮着的蜂鸣器没有发声；蜂鸣器没有发声，是因为一个电子设备出了问题。所以你应该修复电子设备而不是尝试接入引线来启动汽车。</p><h1 id="底线思维">底线思维</h1><p>底线思维，就是当面对一个事情时，设想其最差（或最好）的情况会是什么。</p><p>底线思维特别适用于决策、安抚。举个例子，很多人会焦虑，无外乎焦虑自己未来的职业发展、财务状况、身体健康。对于职业发展，最差会怎么样？最差几年内失业？失业时最差有多少积蓄可供生活开销？最差支持你多久另谋出路？通过底线思维思考，很多时候你会发现最差的情况也不过如此，焦虑的根本原因是虚荣心、攀比和贪婪。</p><h1 id="终局思维">终局思维</h1><p>终局思维，就是当面对一个事情时，设想其最终的情况会是什么。本质上就是用更大的视野去看待事情。</p><p>终局思维同样适用于决策。举个例子，新中国成立时，除了教员外，所有人都认为应该先发展轻工业，后发展重工业。教员则认为新中国的崛起必将引来帝国主义的嫉恨和封锁，甚至战争，彼时如果再发展重工业则为时已晚。</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;随着年龄和阅历的增长，自己看待问题的思考方式也逐步变得理性、全面，这里总结几种自己看待问题的思考方式。&lt;/p&gt;</summary>
    
    
    
    <category term="沉思录" scheme="http://chuquan.me/categories/%E6%B2%89%E6%80%9D%E5%BD%95/"/>
    
    
    <category term="思考" scheme="http://chuquan.me/tags/%E6%80%9D%E8%80%83/"/>
    
  </entry>
  
  <entry>
    <title>2024 年度总结</title>
    <link href="http://chuquan.me/2025/01/02/2024-summary/"/>
    <id>http://chuquan.me/2025/01/02/2024-summary/</id>
    <published>2025-01-02T11:54:42.000Z</published>
    <updated>2025-01-09T13:57:03.087Z</updated>
    
    <content type="html"><![CDATA[<p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/blog-images/WechatIMG2168.jpg?x-oss-process=image/resize,w_600" /></p><span id="more"></span><p>按照惯例，每年做一次回顾总结，让未来的自己能够记得来时走过的路。</p><h1 id="工作第七年">工作第七年</h1><h2 id="项目流水账">项目流水账</h2><p>先说说今年经历的几个大项目吧。</p><p>上半年的一个大项目是直播自建项目，说是自建，其实底层的基础直播能力用的还是中台的SDK。我们做的主要是对 SDK进行封装，包括接口分装、消息的编解码、消息分发、引擎初始化等。当然，最大的工作量仍然是围绕直播搭建上层业务。组内两位同学<span class="citation" data-cites="月龙">@月龙</span> 和 <spanclass="citation" data-cites="马嘉">@马嘉</span> 各自负责搭建 Android/iOS的学生端，我负责搭建 iPad 老师端。</p><p>第二季度同时进行了另一个大项目——播放器重构。Android 端基于 Redux架构进行重构优化，使用 ViewModel 管理状态，通过 flow 实现状态绑定。iOS端基于依赖注入的思想进行模块化改造。项目整体持续了三个月，拆分成三个里程碑分阶段完成。最终的播放器支持服务端下发配置，实现业务功能插件化配置。</p><p>六七八三个月，整个研发团队主要在做小学语文项目，其核心就是利用 AI能力实现辅助教学。六月，我开始调研相关的技术，包括：基于大模型的语音对话的流程和优化的分析，全开麦场景下的VAD 技术等。关于 VAD，我们最终采用了一个开源的预训练的神经网络模型Silero。对于 Android，已经有了对应的封装库，而 iOS并没有。所以我这边要做的就是使用 ONNX 来运行模型，并提供接口实现 VAD检测。年底的时候，我也写了一个 demo，详见 <ahref="https://github.com/baochuquan/ios-vad">ios-vad</a>，后期计划将它封装成pod 库。</p><p>另外，鸿蒙适配也是今年的一个重点非公开项目。为什么是非公开项目呢？因为上层始终保持着不重视、不投入的态度，一切以业务为优先，所以我只能以研发团队的身份低调、非公开推进。我最早在四五月份开始与华为方面进行接触，来来回回沟通了很多次，但是没有公司的支持，很多事情无法顺利开展，比如：驻场协助、适配计划等。以至于后期华为团队多次了解适配进度，我们甚至都不知道该如何答复。</p><p>本身我们也就是一个 10 人的客户端团队，而且清明前离职了一位 iOS同事（后面也没有入职新人），根本没有多余人力完全投入到鸿蒙适配中，所以一直都是一个断断续续的适配状态。早期，只有我能偶尔抽出一点时间来做鸿蒙基建，比如：技术调研、项目配置、工程结构、数据库、网络库、Cookie管理、登录注册等。直到十一之后，客户端团队才迎来了大约两个多月略微空闲的时间，才能够把大部分时间投入到鸿蒙适配之中。整体来说，我们取得的速度还是挺快的，支持了3 个 Tab页及相关落地页（包括播放器），未适配的主要就是学习环节的多个详情页。我在前期支持了一些基本功能，团队可以直接上手进行适配，减少了一些阻碍。因为团队同学的快速学习和通力协作，我们才取得如此不错的成就。当然，鸿蒙App 还未上架，明年仍需努力。</p><p>最后一个大项目是圣诞 Demo课。这个项目十一月初才评审产品稿，当时还没有设计稿，以至于前期还无法投入开发，时间非常紧急。为了能够按时交付，Android/iOS各自投入了 3 人，并让年轻同学 <span class="citation"data-cites="静远">@静远</span>、<span class="citation"data-cites="马嘉">@马嘉</span>设计架构并主导研发。我这边负责项目整体进度，每周和各个团队追踪进度、评估风险。整体而言，项目在一个可控的程度内有序进行。不过，项目最后因为邀请的用户时间有冲突，所以项目延期至元旦之后交付。</p><h2 id="黑客马拉松">黑客马拉松</h2><p>今年有幸参加了一次公司举办的黑客马拉松，主题是AI。我们团队三人，职能分别是服务端 <span class="citation"data-cites="梦林">@梦林</span>、客户端和产品经理 <span class="citation"data-cites="王珊">@王珊</span>，分工协作完成了一个项目——AI旅伴。比赛日只有两天，由于工作量比较大，我们不得不提前一周开始做。比赛过程比较煎熬，prompt调试的进度不是很顺利，直到路演前才调试完。不过好在结果还不错，AI旅伴项目获得了「最具潜力奖」，没有陪跑。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/blog-images/WechatIMG2182.jpg?x-oss-process=image/resize,w_480" /></p><h2 id="人事变动">人事变动</h2><p>所谓「铁打的营盘，流水的兵」，公司里的人事变动在所难免。年初是部门负责人有变动，年中是产品团队大变动。九月份我直属领导也发生了变化，七年老同事+领导+球友<span class="citation" data-cites="碧峰">@碧峰</span> 离职创业，同时<span class="citation" data-cites="潜哥">@潜哥</span>也换了业务线。所幸，我的新领导、课程体验负责人 <span class="citation"data-cites="栋哥">@栋哥</span>人很不错，经验丰富，技术过硬，合作起来非常愉快。产研负责人 <spanclass="citation" data-cites="宝明">@宝明</span>也很不错，虽然工作上直接接触并不多，但是能感受到为人非常坦诚。</p><p>关于裁员，公司各个业务线也是存在的。大环境不好，我们个体能做的也只能是调整好心态，做好自己，以应对一切可能会发生的事情吧。</p><h1 id="业余整点乐子">业余整点乐子</h1><p>曾经的猿辅导是小而美的典范，双减之后虽然大不如前，但是好歹保留了「不加班」的优良传统。WorkLife Balance是一种幸福的状态，很可惜，这样的公司在国内互联网圈子中太少见了。因为不加班，每天回家还能有时间干点自己想干的事，发展自己的爱好，比如看看书、写写博客、做做项目之类的。</p><h2 id="side-project">Side Project</h2><p>今年业余时间，做的项目不多。去年年底做了个 <ahref="https://apps.apple.com/us/app/morph-rest-break-reminder/id6474056217">莫负休息</a>App，今年上半年更新了几个主题，比如：代码雨、魔方之类的，很可惜下半年闲置了。一年下来收入也就几十美元，没达到99 美元的目标，不过在应用商店收获了 50 多个好评，也挺满意了。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rubiks-cube-12.png?x-oss-process=image/resize,w_800" /></p><p>四五月份的时候，我写了一个小工具——<ahref="https://github.com/baochuquan/git-reviewer">git-reviewer</a>。git-reviewer是一个 git 插件，支持 homebrew 安装，可以用来分析一个 Merge Request应该让哪些人来做 Code Review。这个工具底层使用了 <ahref="https://chuquan.me/2023/09/13/myers-difference-algorithm/">Myers差分算法</a>，也算是对我之前学的东西做了一次实践吧。</p><p>十一月份的时候，我用 SwiftUI 写了一个小项目——<ahref="https://github.com/baochuquan/ios-vad">ios-vad</a>。一方面是对我之前调研的VAD 技术做一个总结，另一方面也算是实践一下 SwiftUI吧。项目还有很多要完善的地方，比如抽取 pod 库，希望明年能把它完成。</p><h2 id="写博客">写博客</h2><p>2024 年写了 21篇博客，技术上主要集中在两个领域：图形学、深度学习。</p><p>上半年前几个月主要在看闫令琪老师的《Games101》课程，因为内容非常多，所以我反复看了好多遍。后面，结合自己的理解，针对图形学的相关技术写了一系列博客进行总结。</p><ul><li><ahref="https://chuquan.me/2024/01/15/foundation-of-computer-graphic-01/">计算机图形学基础（1）——线性代数</a></li><li><ahref="https://chuquan.me/2024/01/18/foundation-of-computer-graphic-02/">计算机图形学基础（2）——变换</a></li><li><ahref="https://chuquan.me/2024/03/23/foundation-of-computer-graphic-03/">计算机图形学基础（3）——观测变换</a></li><li><ahref="https://chuquan.me/2024/03/30/foundation-of-computer-graphic-04/">计算机图形学基础（4）——光栅化</a></li><li><ahref="https://chuquan.me/2024/04/05/foundation-of-computer-graphic-05/">计算机图形学基础（5）——着色</a></li><li><ahref="https://chuquan.me/2024/04/10/foundation-of-computer-graphic-06/">计算机图形学基础（6）——几何</a></li><li><ahref="https://chuquan.me/2024/04/20/foundation-of-computer-graphic-07/">计算机图形学基础（7）——辐射度量学</a></li><li><ahref="https://chuquan.me/2024/04/27/foundation-of-computer-graphic-08/">计算机图形学基础（8）——光线追踪</a></li></ul><p>学了图形学之后，尝试用 Threejs 做了个魔方，并把它融合进了莫负休息 App中。与此同时，整理了一篇博客——<ahref="https://chuquan.me/2024/06/01/rubiks-cube-01/">基于 Threejs 实现3D 魔方</a>。</p><p>七至十月，我主要在学习深度学习的一些基础理论，看了好几本书，其中有几本看了两遍，也写了几篇总结性的文章。</p><ul><li><ahref="https://chuquan.me/2024/07/21/neural-network-introduce/">初识人工神经网络（1）——基本原理</a></li><li><ahref="https://chuquan.me/2024/07/31/neural-network-implement/">初识人工神经网络（2）——代码实现</a></li><li><ahref="https://chuquan.me/2024/09/21/deep-learning-layer/">神经网络的分层设计原理</a></li><li><ahref="https://chuquan.me/2024/09/28/cnn-introduction/">卷积神经网络</a></li><li><ahref="https://chuquan.me/2024/10/15/nlp-word-representation/">自然语言处理中的单词含义表示</a></li><li><ahref="https://chuquan.me/2024/10/26/rnn-introduction/">循环神经网络</a></li><li><ahref="https://chuquan.me/2024/11/02/attention-introduction/">语言模型中的注意力机制</a></li></ul><h2 id="黑神话悟空">黑神话·悟空</h2><p>今年最火爆的游戏莫过于《黑神话·悟空》了。我从四年前游戏科学发布的第一个PV 开始，一直在关注这款游戏，我预感游戏要火，所以在 618 赶紧买了PS5，担心游戏出来再 PS5 买会涨价。虽然我玩的游戏品类不多，而且只玩 FPS游戏。我的评价是黑神话真的太上头了，游戏的实际体验比预期还要好非常多，难怪火出圈了。游戏发售后的一个月，我基本上就是每天早早下班，洗完澡后玩到睡觉。如果你还没玩过，真的非常建议你玩一下，战斗系统、音乐、动画、剧情、场景、美术、角色设计、动作设计都是顶级的存在。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/blog-images/WechatIMG2173.jpg?x-oss-process=image/resize,w_800" /></p><h2 id="运动健身">运动健身</h2><p>今年运动状态保持的挺好，希望来年继续保持。</p><p>首先是健身。这一年和健身搭子 <span class="citation"data-cites="昱总">@昱总</span>、<span class="citation"data-cites="奎佑">@奎佑</span> 相互监督，基本上能够保持每周 3-4次健身。不过，因为怕受伤，三大项还是只练卧推。卧推现在能够 80KG做组，动作可以做到比较标准的那种了。今年 8 月 3日，健身一周年，特地测了一下体脂。一整年下来，体脂从 17.4% 下降到了15.0%，骨骼肌增长2.6KG，效果非常明显，打球的队友们也说我明显维度变大了，希望 2025年继续坚持下来。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/blog-images/WechatIMG2169.jpg?x-oss-process=image/resize,w_480" /></p><p>其次是跑步。这一年也是尽可能地每周跑一次十公里，中间因为旅游、游戏、出差等并没有做到每周都跑步。冬季也没有跑，外面的风实在太大。关于跑步，2025年也要继续保持，争取参加一次半马（北京马拉松中签真的太难了）。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/blog-images/WechatIMG2171.jpg?x-oss-process=image/resize,w_480" /></p><p>最后是篮球。今年基本上每周都会打一次球。年中那会儿研究库里的投篮，学到了一段式投篮的精髓，现在投三分球基本上动作不会变形了，出手速度也变快了，命中率也提上来了。这里贴一张老司机<span class="citation" data-cites="碧峰">@碧峰</span>离职前的最后一场球局合影照。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/blog-images/WechatIMG2172.jpg?x-oss-process=image/resize,w_800" /></p><h1 id="户外走一走">户外走一走</h1><h2 id="春节">春节</h2><p>今年春节，和媳妇一起回长兴过节，因为疫情缘故，也是结婚后第一次回家过年。同时也是自老弟军校毕业回军队后，第一次过年相聚，不知下次相聚春节又是何时。</p><p>我们那儿的习俗，正月初一不会走亲戚。于是当天就在村子附近走走逛逛，一路发现周围的变化还是挺大的，记忆中很多村庄都消失了，取而代之的是非常大的综合物流园区、物流港口，也算给周边创造了不少就业岗位。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/blog-images/WechatIMG2174.jpg?x-oss-process=image/resize,w_800" /></p><p>初二初三两天走亲戚，过年热闹的感觉真的挺好。对比前几年因为疫情，在北京过年，真的太凄凉了。紧凑的两天，饭也吃了，照也拍了，下一个目的地就是合肥。回合肥后，因为我听岳父岳母的合肥普通话很费劲，所以除了和媳妇聊天外，我大部分其他时间就是陪小侄女玩，万幸她还记得我这个姑父。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/blog-images/WechatIMG2175.jpg?x-oss-process=image/resize,w_480" /></p><h2 id="外婆北京行">外婆北京行</h2><p>去年，外婆在体检时发现了肺癌，还好是早期肺癌，及时做了手术。今年四月天气合适，外加外婆身体状态有所恢复，所以我妈准备带她来北京玩玩。老人家没来过北京，也算是了却一桩心愿吧。不到一周的时间，带外婆去了北京几大景点，颐和园、天坛、天安门、故宫、北大、长城、圆明园等等。另外，还带外婆尝试了各种菜系，毕竟北京的餐厅种类还是非常多的，有条件可以让老人家也尝尝鲜。唯一的遗憾是，没有带她参观到毛主席纪念堂。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/blog-images/WechatIMG2176.jpg?x-oss-process=image/resize,w_480" /></p><h2 id="成渝九日游">成渝九日游</h2><p>六月初，我请了 5 天年假，凑了一次 9天长假，和媳妇来了一次成渝九日游。</p><p>重庆两天，在魁星楼体验了 8D城市的奇妙感觉，在嘉陵江边看到了千厮门和洪崖洞的巧妙组合，在朝天门见到了双江交汇的独特景观。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/blog-images/WechatIMG2177.jpg?x-oss-process=image/resize,w_480" /></p><p>九寨沟景区，景区公交车在查洼沟、日则沟、树正沟各个景点之间不断载运游客，使得我们一天游玩下来也不会感到那么累。在九寨沟，每个季节都有不同的时令景观，真的如同人间仙境一般，非常值得去游玩。这里有个小插曲，因为浙江有几个城市对阿坝州有援建，所以我也有幸作为湖州群众体验了九寨沟的感恩赠票，免费参观了景区，这里小小地自豪一下。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/blog-images/WechatIMG2178.jpg?x-oss-process=image/resize,w_480" /></p><p>九寨沟待了两天后，我们去了乐山。很可惜乐山大佛在维修栈道，所以我们没有去景区，而是在城市内逛吃。总体来讲，乐山的美食很不错，感觉比成都的要好吃，是一个生活节奏比较慢的城市，来度假的话很舒服。</p><p>最后，就是在成都待了两天，参观了大熊猫繁育基地和三星堆博物馆。大熊猫很傲娇，温度超过26度就不会在室外了，所以以后去还得挑个合适的时间。三星堆博物馆真的很不错，无论是博物馆建筑本身，还是展厅布置、光影效果，亦或馆藏文物，都是非常精妙震撼的，绝对值得参观一次。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/blog-images/WechatIMG2179.jpg?x-oss-process=image/resize,w_480" /></p><h2 id="世界人工智能大会">世界人工智能大会</h2><p>六月休假回来没多久，又跟随公司出差上海，参加了世界人工智能大会。公司以猿力科技作为主体，作为展商参加了这次大会。我们海豚业务线也有展区，我和<span class="citation" data-cites="静远">@静远</span>负责海豚展区的设备调试。展会期间，我也参观了一下其他公司的展厅，大大小小近200家，挨个参观了一遍，比如：华为、BAT、字节、亚马逊、谷歌、特斯拉、宇树科技...整体来说，也是个不错的体验，实实在在见识到了科技的真实落地，比如：宇树科技的机器狗，现场的展示效果非常惊艳。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/blog-images/WechatIMG2183.jpg?x-oss-process=image/resize,w_800" /></p><h2 id="大同周边游">大同周边游</h2><p>十二月，我和媳妇利用周末去大同玩了一遭。去大同玩主要有两个原因，一是离北京近，二是黑神话悟空。第一天去了云冈石窟，数十个石窟中，就属昙曜五窟最为突出。雕刻、壁画精美绝伦，非常震撼，我想这应该算得上是中国古代石雕艺术的巅峰了吧。下午我们又去了华严寺，建筑很有特色，类似于游戏中的旧观音禅院，庙内佛祖、菩萨、星宿、天王等雕塑风格独特且极具年代感。在华严寺，我意外见到了游戏里黄眉住的大雄宝殿，牌匾一模一样。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/blog-images/WechatIMG173.jpg?x-oss-process=image/resize,w_800" /></p><p>第二天我们去了悬空寺和应县木塔。悬空寺在恒山景区，十二月的天气特别冷，零下二十度，手基本伸不出来，在悬空寺打卡拍照没多久就离开了。应县木塔景区内没有其他建筑，不过其本身就已足够传奇，中国建筑史上的一个标志性建筑，也是世界三大奇塔之一。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/blog-images/WechatIMG174.jpg?x-oss-process=image/resize,w_480" /></p><h2 id="博物馆">博物馆</h2><p>今年在深度参观了四个博物馆，分别是中国共产党历史展览馆、中国工艺美术馆、中国国家博物馆、安徽美术馆。</p><p>党史馆基本上就是中国近代史的实物陈列，结合之前看过蒋廷黻写的《中国近代史》，感触很深刻。从1840年开始，中国人民尝试了各种运动和制度试图拯救中国，从清朝末年的虎门销烟、太平天国、戊戌变法、义和团、洋务运动，到民国时期的五四运动、国共合作、北伐战争、抗日战争，最终只有架构于人民史观的中国共产党找到了民族复兴的道路。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/blog-images/WechatIMG2181.jpg?x-oss-process=image/resize,w_480" /></p><p>工艺美术馆主要是展示了中国历史各个朝代的各种工艺品，包括服饰、玉器、瓷器、绘画、壁画、石雕等等。工艺美术馆中最为震撼的作品应该是象牙雕，极致繁琐的细节，不失精致的工艺，令人叹为观止。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/blog-images/WechatIMG2180.jpg?x-oss-process=image/resize,w_480" /></p><p>国家博物馆中的几个主展厅展示了中国历史各个朝代的文物，从古代中国到现代中国，即使是走马观花，也要一整天的时间。国家博物馆我来过很多次了，每次来必看古代中国，五千年的辉煌历史总是让人感受到无比伦比的史诗感。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/blog-images/WechatIMG175.jpg?x-oss-process=image/resize,w_480" /></p><p>安徽美术馆离我家只有一公里左右，但是因为各种原因没有去过。今年回家的时候顺便去看了下。美术馆的内饰设计很漂亮，主要还以美术作品为主，适合拍照打卡。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/blog-images/WechatIMG177.jpg?x-oss-process=image/resize,w_480" /></p><h1 id="我劝同志们多读书">「我劝同志们多读书」</h1><p>毛主席说过：我劝同志们多读书，免得受知识分子的骗。2024 年我完整阅读了13 本书，这里按照时间顺序罗列一下：</p><ul><li>《数学之美》<ul><li>吴军著，虽说是「数学」之美，但内容大多与计算机科学有关，与《计算之魂》有内容重叠，整体还是一本不错的书。不过没有初读《计算之魂》时那么惊艳。</li></ul></li><li>《浪潮之巅》<ul><li>吴军著，科技/互联网从业者必看系列，讲述一系列知名公司的发展史，比如：AT&amp;T、IBM、硅谷、微软、Google、甲骨文、思科、雅虎、惠普、3M、GE...</li></ul></li><li>《中国近代史》<ul><li>蒋廷黻著，中国近代史必看系列，讲述自 1840以来至抗日战争前夕近一百年的历史，主要介绍了无数对外战争的起因、发展、结果，各种不平等条约的签订，以及人民为对抗外部侵略者，争取民族独立和复兴而发起的各种救国运动。强烈推荐。</li></ul></li><li>《赢》<ul><li>杰克韦尔奇著。主要是介绍了杰克韦尔奇对于其在通用电气公司期间在管理方面的理解和感悟。</li></ul></li><li>《Python 神经网络编程》<ul><li>一本非常推荐的深度学习入门书籍，我阅读了两遍，写了两篇博客。<ul><li><ahref="https://chuquan.me/2024/07/21/neural-network-introduce/">初识人工神经网络（1）——基本原理</a></li><li><ahref="https://chuquan.me/2024/07/31/neural-network-implement/">初始人工神经网络（2）——代码实现</a></li></ul></li><li>可能是入门书籍的缘故，这本书侧重于用最简单的语句让读者理解神经网络。因此在专业术语的表述、介绍、体系方面稍显欠缺。</li></ul></li><li>《软件设计哲学》<ul><li>一本软件设计经验总结的书，我看的是中文版，翻译不是很好。不过书中介绍的一些思想还是很独特的。比如：将信息论与软件开发相结合，什么时候应该暴露信息，什么时候应该隐藏信息；软件开发的二次设计思想等等，推荐阅读。</li></ul></li><li>《深度学习入门：基于 Python 的理论与实现》<ul><li>一本非常推荐的深度学习入门书籍，我读了两遍，建议和《Python神经网络编程》结合一起看，弥补了后者的缺陷，但是内容比较多，理论稍微难一点。</li></ul></li><li>《深度学习进阶：自然语言处理》<ul><li>如果你想系统性地学习GPT，这版书是你的入门首选，介绍了自然语言处理发展过程中的一些关键性技术。</li><li>这本书我也读了两遍，并且总结性地写了几篇博客。<ul><li><ahref="https://chuquan.me/2024/10/15/nlp-word-representation/">自然语言处理中的单词含义表示</a></li><li><ahref="https://chuquan.me/2024/10/26/rnn-introduction/">循环神经网络</a></li><li><ahref="https://chuquan.me/2024/11/02/attention-introduction/">语言模型中的注意力机制</a></li></ul></li></ul></li><li>《这就是 ChatGPT》<ul><li>虽然是科普书籍，但是写得不够浅显易懂，写得不明不白。组织结构也不是特别清晰，需要有一定的基础才能够理解，不推荐。</li></ul></li><li>《GPT 图解》<ul><li>类似于 Head First类型的书，与经典的《HTTP图解》等书有差距，不过代码示例写得不错。</li></ul></li><li>《FFmpeg 入门详解——音视频原理及应用》<ul><li>废话多，重复内容多，让人抓不住重点。干活不够突出，技术推导不清晰。不过好歹介绍了一些基本概念。</li></ul></li><li>《红星照耀中国》<ul><li>一本非常推荐的红色经典。1937 年出版，埃德加·斯诺著，介绍了在 1936 年6 月至 10月，西安事变之前几个月，作者在延安的所见所闻。包含了几位重要领导人的采访记录，关于他们自述的人生经历。另外还包含了大量的农民、士兵的采访记录，以及作者自己对于时局的看法。</li><li>书的结尾，斯诺悲伤地写道：也许我是看到他们活着的最后一个外国人了。在他看来，这群理想主义者想要改变中国，真的太难了。没想到最后他们真的做到了。</li></ul></li><li>《Scrum 要素》<ul><li>主要介绍 scrum的几个重要的概念和要素，以及各个要素的作用是什么，值得阅读。</li></ul></li></ul><h1 id="一些感悟">一些感悟</h1><p>2024 年，我感觉自己最大的变化就是认知水平有了明显地提升。</p><p>在技术方面，能够结合知识储备和第一性原理，发现很多技术设计的基本出发点和共通点。在其他方面，对社会科学产生了比较大的兴趣，也开始思考不同规模的团队是如何管理，小到团队管理，企业管理，大到国家层面的内政管理、外交关系。主要也是意识到团队管理的重要性，虽然很多伟大的发现和发明确实是个别天才的成果，但是将这些成果转换成产品或服务时则更需要发挥团队的力量。</p><h1 id="写在最后">写在最后</h1><p>总体来说，2024年，我个人的收获和成长还是挺多的，希望来年继续保持！最后，祝大家 2025年元旦快乐！</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;&lt;img
src=&quot;https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/blog-images/WechatIMG2168.jpg?x-oss-process=image/resize,w_600&quot; /&gt;&lt;/p&gt;</summary>
    
    
    
    <category term="生活" scheme="http://chuquan.me/categories/%E7%94%9F%E6%B4%BB/"/>
    
    
    <category term="年度总结" scheme="http://chuquan.me/tags/%E5%B9%B4%E5%BA%A6%E6%80%BB%E7%BB%93/"/>
    
  </entry>
  
  <entry>
    <title>正向理解 Objective-C AutoReleasePool 设计原理</title>
    <link href="http://chuquan.me/2024/12/27/memory-management-autoreleasepool/"/>
    <id>http://chuquan.me/2024/12/27/memory-management-autoreleasepool/</id>
    <published>2024-12-27T15:56:40.000Z</published>
    <updated>2025-02-06T11:26:26.582Z</updated>
    
    <content type="html"><![CDATA[<p>关于 Objective-C 的 AutoReleasePool的机制，网上有非常多的文章对源码进行了详尽的剖析。不过，这些文章大多迷失在代码细节之中，没有从整体设计来进行介绍。本文，我们将从正向设计的角度来进行介绍。</p><span id="more"></span><p>这里我们先预设两个问题，后续再进行解答。</p><ul><li>为什么 AutoReleasePool 底层是双向链表结构？</li><li>为什么 AutoReleasePool 采用基于 AutoReleasePoolPage的分页机制？</li></ul><h1 id="栈结构">栈结构</h1><p>一切要从栈开始说起。栈（Stack）是一种遵循先进后出（FILO，First InLastOut）的逻辑数据结构。栈有两个重要属性：栈底指针、栈顶指针。栈的底层实现一般有两种，分别是：</p><ul><li>基于数组实现</li><li>基于链表实现</li></ul><h2 id="基于数组的栈">基于数组的栈</h2><p>基于数组实现的栈结构，一般会使用动态数组来存储元素。</p><p>对于栈底指针，本质上保存了一个指向数组头部元素的索引；对于栈顶指针，也会维持一个指向数组尾部元素的索引，当执行push 操作时，索引加一，当执行 pop 操作时，索引减一。</p><p>要注意的是，动态数组初始化时会分配一定大小的连续内存空间，当栈空间不足时（本质上是底层动态数组空间不足），会重新分配一段更大的连续内存空间，将原来的元素复制到新的内存空间中，并释放旧的内存空间。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/autoreleasepool-01.png?x-oss-process=image/resize,w_800" /></p><h2 id="基于链表的栈">基于链表的栈</h2><p>基于链表的栈结构，是绝大多数标准库的选择，比如：C++ STL 中的 stack就是基于 list 实现的，这里的 list 是一个双向链表。</p><p>为什么要使用双向链表？根本原因在于栈顶指针的控制。当执行 push操作时，必须要正向延伸序列，此时需要有一个指向正方向的指针；当执行 pop操作时，必须要反向回缩序列，此时则需要一个指向反方向的指针。对此，双向链表完美地契合了这个要求。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/autoreleasepool-02.png?x-oss-process=image/resize,w_800" /></p><p>那么为什么栈结构大多数使用双向链表实现，而不是动态数组呢？根本原因在于动态数组要求占用一段连续的内存空间，对于内存提出了更高的要求；而且当分配的内存空间不足时，需要重新分配一段更大的内存空间，同时还要拷贝原始数据，产生了更大的性能开销。</p><h1 id="函数栈帧">函数栈帧</h1><p>如下所示是 YYImage 中的一个 AutoReleasePool的使用示例，使用大括号管理其作用域下的对象，这与函数（或方法）使用大括号管理其作用域下的局部变量非常相似。</p><figure class="highlight objc"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">@autoreleasepool</span> &#123;</span><br><span class="line">    <span class="built_in">UIImage</span> *newImage = [<span class="built_in">UIImage</span> imageWithCGImage:imageRef];</span><br><span class="line">    <span class="keyword">if</span> (newImage) &#123;</span><br><span class="line">        <span class="keyword">if</span> (hasAlpha) &#123;</span><br><span class="line">            data = <span class="built_in">UIImagePNGRepresentation</span>([<span class="built_in">UIImage</span> imageWithCGImage:imageRef]);</span><br><span class="line">        &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">            data = <span class="built_in">UIImageJPEGRepresentation</span>([<span class="built_in">UIImage</span> imageWithCGImage:imageRef], <span class="number">0.9</span>); <span class="comment">// same as Apple&#x27;s example</span></span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>对此，我们来回顾一下函数栈帧的基本工作原理。<strong>当函数运行时，进程的栈空间中会为其创建一个对应的栈帧（StackFrame）来记录运行时产生的相关信息，当函数返回时则会销毁对应的栈帧</strong>。</p><h2 id="栈帧结构">栈帧结构</h2><p>下图所示为栈帧的通用结构。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/autoreleasepool-03.png?x-oss-process=image/resize,w_800" /></p><p>每个栈帧会通过两个寄存器来表示其内存界限，分别是：</p><ul><li>EBP（Extended BasePointer），保存当前栈帧的基址，即开始位置，主要用于访问函数的参数和局部变量。</li><li>ESP（Extended StackPointer），保存当前栈顶的地址，即结束位置，主要用于进行栈操作。</li></ul><p>栈帧内部保存运行时的相关信息，比如：</p><ul><li>调用者的栈帧基址，用于恢复调用者栈帧基址</li><li>调用者的寄存器，用于恢复调用者的状态</li><li>局部变量</li><li>下一个函数的参数</li><li>返回地址，用于表示调用者调用完成后继续执行的指令地址</li></ul><h1 id="autoreleasepool-内存管理">AutoReleasePool 内存管理</h1><p>AutoReleasePool的用法与函数调用类似，其底层实现与函数栈帧类似。<strong>AutoReleasePool的管理是基于栈结构设计的，栈结构底层使用双向链表实现；而函数栈帧的栈结构底层则是使用动态数组实现，本质上是连续的栈空间</strong>。</p><h2 id="设计思想">设计思想</h2><p>一般而言，链表节点是存储空间固定的数据结构，然而 AutoReleasePool管理的对象数量是不固定的。那么该如何解决这个问题？</p><p>对此，提出了页面的概念——AutoReleasePoolPage。每个 AutoReleasePoolPage可以存储固定数量的对象指针。一个 AutoReleasePool 由一个或多个AutoReleasePoolPage 组成，其数量取决于要管理的对象数量。AutoReleasePool底层就是以 AutoReleasePoolPage 作为链表节点实现的。</p><h2 id="结构实现">结构实现</h2><p>由于一个 AutoReleasePool 由一个或多个 AutoReleasePoolPage组成，我们很容易想到如下所示的一种直观、清晰的结构。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/autoreleasepool-04.png?x-oss-process=image/resize,w_800" /></p><p>上图所示的结构中，一个 AutoReleasePoolPage 只属于一个AutoReleasePool。细心的读者可能会发现，这种结构存在一个问题，即<strong>存在内存碎片</strong>。比如：AutoReleasePoolPage 1中，只存储了一个对象地址，剩余的空间都没有被利用。</p><p>为了解决这个问题，AutoReleasePool 的优化方法是：</p><ul><li>多个 AutoReleasePool 可以共享一个 AutoReleasePoolPage</li><li>不同 AutoReleasePool 之间使用 <strong>哨兵对象</strong>POOL_SENTINEL 来划分边界</li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/autoreleasepool-05.png?x-oss-process=image/resize,w_800" /></p><p>通过哨兵对象，我们解决了内存碎片的问题，当然也改变了 AutoReleasePool的边界定义。</p><ul><li>当我们声明一个 AutoReleasePool时，必须添加一个新哨兵对象，从而标识当前 AutoReleasePool 的边界。</li><li>当我们释放一个 AutoReleasePool时，必须追溯最近的哨兵对象，从而找到当前 AutoReleasePool 的边界。</li></ul><p>在此基础上，我们可以大致描绘出 AutoReleasePool 是如何管理对象的。</p><ul><li>当 AutoReleasePool中实例化一个对象时，我们根据链表的尾指针，正向遍历，查看末端的AutoReleasePoolPage 是否未满。<ul><li>如果未满，则将对象指针存入 AutoReleasePoolPage 中；</li><li>否则，链表尾部新增一个 AutoReleasePoolPage后，再将对象指针存入其中。</li></ul></li><li>当 AutoReleasePool要释放全部对象时，我们根据链表的尾指针，反向遍历，找到最近的哨兵对象，并把中间部分的AutoReleasePoolPage 、对象指针、哨兵对象全部释放。</li></ul><h1 id="代码分析">代码分析</h1><p>下面，我们通过分析 Objective-C 的 <code>main</code> 函数来了解AutoReleasePool 的具体执行逻辑。<code>main.m</code>中的定义如下所示，可以看出整个应用被包含在了一个 AutoReleasePool中。</p><figure class="highlight objc"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">int</span> main(<span class="type">int</span> argc, <span class="type">char</span> * argv[]) &#123;</span><br><span class="line">    <span class="built_in">NSString</span> * appDelegateClassName;</span><br><span class="line">    <span class="keyword">@autoreleasepool</span> &#123;</span><br><span class="line">        <span class="comment">// Setup code that might create autoreleased objects goes here.</span></span><br><span class="line">        appDelegateClassName = <span class="built_in">NSStringFromClass</span>([AppDelegate <span class="keyword">class</span>]);</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> <span class="built_in">UIApplicationMain</span>(argc, argv, <span class="literal">nil</span>, appDelegateClassName);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="autoreleasepool"><span class="citation"data-cites="autoreleasepool">@autoreleasepool</span></h2><p>我们使用 <code>clang</code> 将 <code>main.m</code> 重写转换成 C++实现，如下所示。</p><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">$ xcrun --sdk iphonesimulator clang -rewrite-objc main.m</span><br></pre></td></tr></table></figure><p>经过转换后会生成一个 <code>main.cpp</code> 文件，<code>main</code>函数的内部实现如下所示。</p><figure class="highlight c++"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="type">int</span> <span class="title">main</span><span class="params">(<span class="type">int</span> argc, <span class="type">char</span> * argv[])</span> </span>&#123;</span><br><span class="line">    NSString * appDelegateClassName;</span><br><span class="line">    <span class="comment">/* @autoreleasepool */</span> &#123; __AtAutoreleasePool __autoreleasepool; </span><br><span class="line">        appDelegateClassName = <span class="built_in">NSStringFromClass</span>(((<span class="built_in">Class</span> (*)(id, SEL))(<span class="type">void</span> *)objc_msgSend)((id)<span class="built_in">objc_getClass</span>(<span class="string">&quot;AppDelegate&quot;</span>), <span class="built_in">sel_registerName</span>(<span class="string">&quot;class&quot;</span>)));</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> <span class="built_in">UIApplicationMain</span>(argc, argv, __null, appDelegateClassName);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>其中，<code>__AtAutoreleasePool</code>结构的定义如下所示，构造函数执行了 <code>objc_autoreleasePoolPush</code>函数，析构函数执行了 <code>objc_autoreleasePoolPop</code>函数，两者分别对应 AutoReleasePool 的入栈操作和出栈操作。</p><figure class="highlight c++"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">struct</span> <span class="title class_">__AtAutoreleasePool</span> &#123;</span><br><span class="line">  __AtAutoreleasePool() &#123;</span><br><span class="line">    atautoreleasepoolobj = <span class="built_in">objc_autoreleasePoolPush</span>();</span><br><span class="line">  &#125;</span><br><span class="line">  ~__AtAutoreleasePool() &#123;</span><br><span class="line">    <span class="built_in">objc_autoreleasePoolPop</span>(atautoreleasepoolobj);</span><br><span class="line">  &#125;</span><br><span class="line">  <span class="type">void</span> * atautoreleasepoolobj;</span><br><span class="line">&#125;;</span><br></pre></td></tr></table></figure><p><code>@autoreleasepool</code>关键词本质上隐藏了栈操作的管理细节，让代码更加简洁、可靠。<code>@autoreleasepool</code>构造了一个作用域，在作用域的起始执行构造函数，在作用域的结尾执行析构函数。总体而言，<code>main</code>函数的实现可以简化成如下所示。</p><figure class="highlight c++"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="type">int</span> <span class="title">main</span><span class="params">(<span class="type">int</span> argc, <span class="type">char</span> * argv[])</span> </span>&#123;</span><br><span class="line">    NSString * appDelegateClassName;</span><br><span class="line">    <span class="comment">/* @autoreleasepool */</span> &#123; </span><br><span class="line">        atautoreleasepoolobj = <span class="built_in">objc_autoreleasePoolPush</span>();</span><br><span class="line">        </span><br><span class="line">        <span class="comment">// do anything</span></span><br><span class="line">        </span><br><span class="line">        <span class="built_in">objc_autoreleasePoolPop</span>(atautoreleasepoolobj);</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> <span class="built_in">UIApplicationMain</span>(argc, argv, __null, appDelegateClassName);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="autoreleasepool-1">AutoReleasePool</h2><p>为了深入分析 AutoReleasePool 的实现，我们需要阅读 objc 的源码，详见<a href="https://github.com/opensource-apple/objc4">传送门</a>。</p><p>在上一节，我们注意到有两个函数：<code>objc_autoreleasePoolPush</code>和 <code>objc_autoreleasePoolPop</code>。它们在 objc源码中有对应的实现，如下所示。这里就涉及到了 AutoReleasePool的底层实现原理，其核心就是 <code>AutoReleasePoolPage</code>。</p><figure class="highlight c++"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="type">void</span> *</span></span><br><span class="line"><span class="function"><span class="title">objc_autoreleasePoolPush</span><span class="params">(<span class="type">void</span>)</span> </span>&#123;</span><br><span class="line">    <span class="keyword">if</span> (UseGC) <span class="keyword">return</span> nil;</span><br><span class="line">    <span class="keyword">return</span> AutoreleasePoolPage::<span class="built_in">push</span>();</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">void</span></span></span><br><span class="line"><span class="function"><span class="title">objc_autoreleasePoolPop</span><span class="params">(<span class="type">void</span> *ctxt)</span> </span>&#123;</span><br><span class="line">    <span class="keyword">if</span> (UseGC) <span class="keyword">return</span>;</span><br><span class="line">    AutoreleasePoolPage::<span class="built_in">pop</span>(ctxt);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="autoreleasepoolpage">AutoReleasePoolPage</h2><p>这里我们再来看一下 <code>AutoReleasePoolPage</code>的定义，如下所示。<code>AutoReleasePoolPage</code> 定义了两个指针<code>parent</code> 和 <code>child</code>，它分别对应上述图中的<code>prev</code> 和<code>next</code>，作为实现双向链表的关键指针。此外，其自定义了<code>new</code> 方法，内部可以分配 4096字节的内存空间，用于存储固定属性和对象指针。</p><p><code>AutoReleasePoolPage</code> 还定义了 <code>next</code>指针，这个指针很重要，类似于栈顶指针，当添加或删除对象后，<code>next</code>指针会更新为下一个可存储的内存地址。</p><figure class="highlight objc"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">class</span> AutoreleasePoolPage &#123;</span><br><span class="line">    PAGE_MAX_SIZE；                     <span class="comment">// 最大 size 4096 字节</span></span><br><span class="line">    magic_t <span class="keyword">const</span> magic;                <span class="comment">// 用来校验 AutoreleasePoolPage 的结构是否完整</span></span><br><span class="line">    <span class="type">id</span> *next;                           <span class="comment">// 指向下一个即将产生的 autoreleased 对象的存放位置，类似于栈顶指针</span></span><br><span class="line">    pthread_t <span class="keyword">const</span> thread;             <span class="comment">// 指向当前线程，一个 AutoreleasePoolPage 只会对应一个线程，但一个线程可以对应多个 AutoreleasePoolPage；</span></span><br><span class="line">    AutoreleasePoolPage * <span class="keyword">const</span> parent; <span class="comment">// 指向父节点，第一个节点的 parent 值为 nil；</span></span><br><span class="line">    AutoreleasePoolPage *child;         <span class="comment">// 指向子节点，最后一个节点的 child 值为 nil；</span></span><br><span class="line">    uint32_t <span class="keyword">const</span> depth;               <span class="comment">// 表示深度，第一个 page 的 depth 为 0，往后每递增一个 page，depth 会加 1；</span></span><br><span class="line">    uint32_t hiwat;</span><br><span class="line">    </span><br><span class="line">    <span class="comment">// SIZE-sizeof(*this) bytes of contents follow</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">static</span> <span class="type">void</span> * operator new(size_t size) &#123;</span><br><span class="line">        <span class="keyword">return</span> malloc_zone_memalign(malloc_default_zone(), SIZE, SIZE);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/autoreleasepool-06.png?x-oss-process=image/resize,w_800" /></p><h2 id="objc_autoreleasepoolpush">objc_autoreleasePoolPush</h2><p><code>objc_autoreleasePoolPush</code> 内部调用了<code>AutoReleasePoolPage</code> 的 <code>push</code>方法，其实现如下所示。可以看出，<code>push</code> 方法的本质就是想AutoReleasePage 中存入一个哨兵对象 <code>POOL_SENTINEL</code>，用于标识AutoReleasePool 的边界。这个过程中会遇到一些主要的分支情况，比如：</p><ul><li>当前没有任何页面时，需要初始化一个页面</li><li>当前页面已满时，需要添加一个新页面</li><li>当前页面未满时，则直接存入对象指针</li></ul><p>这些分支情况，在 <code>push</code>方法中均有体现，相关逻辑详见代码具体实现。<code>push</code> 方法中通过<code>autoreleaseFast</code> 方法存储哨兵对象。事实上，我们使用 ARC管理对象时，所有的对象都是通过 <code>autoreleaseFast</code> 方法存入<code>AutoReleasePoolPage</code> 中的。</p><blockquote><p>hotPage 表示末尾最后一个非空页面。</p></blockquote><figure class="highlight c++"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="type">static</span> <span class="keyword">inline</span> <span class="type">void</span> *<span class="title">push</span><span class="params">()</span> </span>&#123;</span><br><span class="line">    id *dest = <span class="built_in">autoreleaseFast</span>(POOL_SENTINEL);</span><br><span class="line">    <span class="keyword">return</span> dest;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 管理对象</span></span><br><span class="line"><span class="function"><span class="type">static</span> <span class="keyword">inline</span> id *<span class="title">autoreleaseFast</span><span class="params">(id obj)</span> </span>&#123;</span><br><span class="line">    AutoreleasePoolPage *page = <span class="built_in">hotPage</span>();</span><br><span class="line">    <span class="keyword">if</span> (page &amp;&amp; !page-&gt;<span class="built_in">full</span>()) &#123;</span><br><span class="line">        <span class="comment">// 当前页面存在，且未满时，直接存入对象</span></span><br><span class="line">        <span class="keyword">return</span> page-&gt;<span class="built_in">add</span>(obj);</span><br><span class="line">    &#125; <span class="keyword">else</span> <span class="keyword">if</span> (page) &#123;</span><br><span class="line">        <span class="comment">// 当前页面存在，但已满时，添加一个页面，再存入对象</span></span><br><span class="line">        <span class="keyword">return</span> <span class="built_in">autoreleaseFullPage</span>(obj, page);</span><br><span class="line">    &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">        <span class="comment">// 当前页面不存在时，初始化一个页面，再存入对象</span></span><br><span class="line">        <span class="keyword">return</span> <span class="built_in">autoreleaseNoPage</span>(obj);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 当前页面存在的逻辑</span></span><br><span class="line"><span class="comment">// 当前页面已满时，则沿着链表 child 指针，正向遍历，即栈顶方向，查找未满的页面。如果没找到，则初始化一个页面加入链表尾部，即栈顶。</span></span><br><span class="line"><span class="comment">// 最后将对象指针加入该未满的页面中。</span></span><br><span class="line"><span class="function">id *<span class="title">autoreleaseFullPage</span><span class="params">(id obj, AutoreleasePoolPage *page)</span> </span>&#123;</span><br><span class="line">    <span class="built_in">assert</span>(page == <span class="built_in">hotPage</span>());</span><br><span class="line">    <span class="built_in">assert</span>(page-&gt;<span class="built_in">full</span>()  ||  DebugPoolAllocation);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">do</span> &#123;</span><br><span class="line">        <span class="keyword">if</span> (page-&gt;child) page = page-&gt;child;</span><br><span class="line">        <span class="keyword">else</span> page = <span class="keyword">new</span> <span class="built_in">AutoreleasePoolPage</span>(page);</span><br><span class="line">    &#125; <span class="keyword">while</span> (page-&gt;<span class="built_in">full</span>());</span><br><span class="line"></span><br><span class="line">    <span class="built_in">setHotPage</span>(page);</span><br><span class="line">    <span class="keyword">return</span> page-&gt;<span class="built_in">add</span>(obj);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 当前页面不存在的逻辑</span></span><br><span class="line"><span class="comment">// 当链表中没有任何页面时，初始化一个页面，其 parent 设置为 nil，即链表头。</span></span><br><span class="line"><span class="comment">// 此时，如果要加入的对象不是哨兵对象，则加入一个哨兵对象，作为 AutoReleasePool 的边界。然后再加入对象地址。</span></span><br><span class="line"><span class="function">id *<span class="title">autoreleaseNoPage</span><span class="params">(id obj)</span> </span>&#123;</span><br><span class="line">    <span class="comment">// No pool in place.</span></span><br><span class="line">    <span class="built_in">assert</span>(!<span class="built_in">hotPage</span>());</span><br><span class="line"></span><br><span class="line">    <span class="keyword">if</span> (obj != POOL_SENTINEL  &amp;&amp;  DebugMissingPools) &#123;</span><br><span class="line">        <span class="comment">// We are pushing an object with no pool in place, </span></span><br><span class="line">        <span class="comment">// and no-pool debugging was requested by environment.</span></span><br><span class="line">        _objc_inform(<span class="string">&quot;MISSING POOLS: Object %p of class %s &quot;</span></span><br><span class="line">                     <span class="string">&quot;autoreleased with no pool in place - &quot;</span></span><br><span class="line">                     <span class="string">&quot;just leaking - break on &quot;</span></span><br><span class="line">                     <span class="string">&quot;objc_autoreleaseNoPool() to debug&quot;</span>, </span><br><span class="line">                     (<span class="type">void</span>*)obj, <span class="built_in">object_getClassName</span>(obj));</span><br><span class="line">        <span class="built_in">objc_autoreleaseNoPool</span>(obj);</span><br><span class="line">        <span class="keyword">return</span> nil;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Install the first page.</span></span><br><span class="line">    AutoreleasePoolPage *page = <span class="keyword">new</span> <span class="built_in">AutoreleasePoolPage</span>(nil);</span><br><span class="line">    <span class="built_in">setHotPage</span>(page);</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Push an autorelease pool boundary if it wasn&#x27;t already requested.</span></span><br><span class="line">    <span class="keyword">if</span> (obj != POOL_SENTINEL) &#123;</span><br><span class="line">        page-&gt;<span class="built_in">add</span>(POOL_SENTINEL);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Push the requested object.</span></span><br><span class="line">    <span class="keyword">return</span> page-&gt;<span class="built_in">add</span>(obj);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="objc_autoreleasepoolpop">objc_autoreleasePoolPop</h2><p><code>objc_autoreleasePoolPop</code> 内部调用了<code>AutoReleasePoolPage</code> 的 <code>pop</code>方法。<code>pop</code> 方法的参数 <code>token</code> 则是当前AutoReleasePool 的哨兵对象。</p><figure class="highlight c++"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="type">static</span> <span class="keyword">inline</span> <span class="type">void</span> <span class="title">pop</span><span class="params">(<span class="type">void</span> *token)</span> </span>&#123;</span><br><span class="line">    AutoreleasePoolPage *page;</span><br><span class="line">    id *stop;</span><br><span class="line"></span><br><span class="line">    page = <span class="built_in">pageForPointer</span>(token);</span><br><span class="line">    stop = (id *)token;</span><br><span class="line">    <span class="keyword">if</span> (DebugPoolAllocation  &amp;&amp;  *stop != POOL_SENTINEL) &#123;</span><br><span class="line">        <span class="comment">// This check is not valid with DebugPoolAllocation off</span></span><br><span class="line">        <span class="comment">// after an autorelease with a pool page but no pool in place.</span></span><br><span class="line">        _objc_fatal(<span class="string">&quot;invalid or prematurely-freed autorelease pool %p; &quot;</span>, </span><br><span class="line">                    token);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">if</span> (PrintPoolHiwat) <span class="built_in">printHiwat</span>();</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 释放对象</span></span><br><span class="line">    page-&gt;<span class="built_in">releaseUntil</span>(stop);</span><br><span class="line"></span><br><span class="line">    <span class="comment">// memory: delete empty children</span></span><br><span class="line">    <span class="keyword">if</span> (DebugPoolAllocation  &amp;&amp;  page-&gt;<span class="built_in">empty</span>()) &#123;</span><br><span class="line">        <span class="comment">// special case: delete everything during page-per-pool debugging</span></span><br><span class="line">        AutoreleasePoolPage *parent = page-&gt;parent;</span><br><span class="line">        page-&gt;<span class="built_in">kill</span>();</span><br><span class="line">        <span class="built_in">setHotPage</span>(parent);</span><br><span class="line">    &#125; <span class="keyword">else</span> <span class="keyword">if</span> (DebugMissingPools  &amp;&amp;  page-&gt;<span class="built_in">empty</span>()  &amp;&amp;  !page-&gt;parent) &#123;</span><br><span class="line">        <span class="comment">// special case: delete everything for pop(top) </span></span><br><span class="line">        <span class="comment">// when debugging missing autorelease pools</span></span><br><span class="line">        page-&gt;<span class="built_in">kill</span>();</span><br><span class="line">        <span class="built_in">setHotPage</span>(nil);</span><br><span class="line">    &#125; </span><br><span class="line">    <span class="keyword">else</span> <span class="keyword">if</span> (page-&gt;child) &#123;</span><br><span class="line">        <span class="comment">// hysteresis: keep one empty child if page is more than half full</span></span><br><span class="line">        <span class="keyword">if</span> (page-&gt;<span class="built_in">lessThanHalfFull</span>()) &#123;</span><br><span class="line">            page-&gt;child-&gt;<span class="built_in">kill</span>();</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">else</span> <span class="keyword">if</span> (page-&gt;child-&gt;child) &#123;</span><br><span class="line">            page-&gt;child-&gt;child-&gt;<span class="built_in">kill</span>();</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 对象释放</span></span><br><span class="line"><span class="function"><span class="type">void</span> <span class="title">releaseUntil</span><span class="params">(id *stop)</span> </span>&#123;</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">while</span> (<span class="keyword">this</span>-&gt;next != stop) &#123;</span><br><span class="line">        <span class="comment">// 设置 page 为当前非空页面</span></span><br><span class="line">        AutoreleasePoolPage *page = <span class="built_in">hotPage</span>();</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 通过 parent 指针，反向遍历，即栈顶方向</span></span><br><span class="line">        <span class="comment">// 将尾指针指向非空的页面</span></span><br><span class="line">        <span class="keyword">while</span> (page-&gt;<span class="built_in">empty</span>()) &#123;</span><br><span class="line">            page = page-&gt;parent;</span><br><span class="line">            <span class="built_in">setHotPage</span>(page);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        page-&gt;<span class="built_in">unprotect</span>();</span><br><span class="line">        <span class="comment">// 地址递减，并释放对应位置</span></span><br><span class="line">        id obj = *--page-&gt;next;</span><br><span class="line">        <span class="built_in">memset</span>((<span class="type">void</span>*)page-&gt;next, SCRIBBLE, <span class="built_in">sizeof</span>(*page-&gt;next));</span><br><span class="line">        page-&gt;<span class="built_in">protect</span>();</span><br><span class="line">        </span><br><span class="line">        <span class="comment">// 如果对应位置存储了对象，则进行内存释放</span></span><br><span class="line">        <span class="keyword">if</span> (obj != POOL_SENTINEL) &#123;</span><br><span class="line">            <span class="built_in">objc_release</span>(obj);</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="built_in">setHotPage</span>(<span class="keyword">this</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>我们可以看到对象释放的关键方法<code>releaseUntil</code>。其通过循环不断寻找非空的页面，同时对于每个页面，使用其<code>next</code>指针，逐步释放每一个对象及其内存，直到找到目标对象，即哨兵对象。</p><p>需要注意的是，<code>releaseUntil</code>只是重置了其记录的对象指针，并释放了对象的内存，但是并没有释放<code>AutoReleasePoolPage</code> 本身。如果 AutoReleasePool管理的对象非常多，那岂不是有很多未使用的<code>AutoReleasePoolPage</code> 没有被使用，也没有被释放吗？</p><p>对此，<code>pop</code> 方法中的最后部分解决了这个问题，它通过<code>kill</code> 方法来释放空的页面。这里有两种情况：</p><ul><li>当 hotPage存储已过半时，仅保留一个空页面。因为页面快要满了，很快就会分配一个新的页面，为了提高性能，保存一个即将使用的空页面。</li><li>当 hotPage 存储未过半时，不包含任何空页面。</li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/autoreleasepool-07.png?x-oss-process=image/resize,w_800" /></p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/autoreleasepool-08.png?x-oss-process=image/resize,w_800" /></p><h1 id="总结">总结</h1><p>首先，我们介绍了栈的两种实现方式，分别是数组和双向链表。</p><p>其次，我们对比了函数定义和 AutoReleasePool定义，两者具有相似之处。作为对比，我们介绍了函数调用是如何通过栈帧管理局部变量的。进一步，我们又介绍了AutoReleasePool 管理对象的思路。</p><p>考虑到性能问题，AutoReleasePool不能像函数调用栈一样，通过类似数组的方式，即连续内存空间来管理对象，而是通过双向链表的方式进行管理。于是，我们进一步设想了链表节点的定义，即AutoReleasePoolPage 的定义。</p><p>于是，我们设计了一种直观的管理方式：一个 AutoReleasePool独享一个或多个AutoReleasePoolPage。但是这种方式存在内存碎片问题。对此，我们重新设计了一种管理方式：一个AutoReleasePool 可以被多个 AutoReleasePool共享。其本质上通过哨兵对象来进行定义边界，这种设计正是 AutoReleasePool的设计方案。</p><p>通过正向设计，我们可以深刻理解 AutoReleasePool的设计思想及其设计原因。</p><p>最后，我们再来回顾文章开头的两个问题，想必你已经有答案了。</p><ul><li>问：为什么 AutoReleasePool 底层是双向链表结构？</li><li>答：AutoReleasePool的本质是通过栈来管理对象，而双向链表是应用层实现栈的一种效率最高的数据结构。</li><li>问：为什么 AutoReleasePool 采用基于 AutoReleasePoolPage的分页机制？</li><li>答：基于双向链表结构的设计中，必须要设计一个固定结构的链表节点，AutoReleasePoolPage正是链表节点的设计。</li></ul><h1 id="参考">参考</h1><ol type="1"><li><ahref="https://acodercat.github.io/2020/08/19/function-stack-frame/">函数栈帧的创建与销毁</a></li><li><ahref="https://www.cnblogs.com/zlcxbb/p/5759776.html">函数调用过程栈帧变化详解</a></li><li>《程序员的自我修养——链接、装载与库》</li><li><a href="https://github.com/opensource-apple/objc4">objc</a></li><li><a href="https://draveness.me/autoreleasepool/">自动释放池的前世今生---- 深入解析 autoreleasepool</a></li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;关于 Objective-C 的 AutoReleasePool
的机制，网上有非常多的文章对源码进行了详尽的剖析。不过，这些文章大多迷失在代码细节之中，没有从整体设计来进行介绍。本文，我们将从正向设计的角度来进行介绍。&lt;/p&gt;</summary>
    
    
    
    <category term="Objective-C" scheme="http://chuquan.me/categories/Objective-C/"/>
    
    
    <category term="内存管理" scheme="http://chuquan.me/tags/%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86/"/>
    
    <category term="AutoReleasePool" scheme="http://chuquan.me/tags/AutoReleasePool/"/>
    
  </entry>
  
  <entry>
    <title>语言模型中的注意力机制</title>
    <link href="http://chuquan.me/2024/11/02/attention-introduction/"/>
    <id>http://chuquan.me/2024/11/02/attention-introduction/</id>
    <published>2024-11-02T07:52:50.000Z</published>
    <updated>2024-11-02T07:54:05.774Z</updated>
    
    <content type="html"><![CDATA[<p>前一篇文章 <ahref="https://chuquan.me/2024/10/26/rnn-introduction/">循环神经网络</a>中我们介绍了 RNN、LSTM、GRU等。本文，我们将以此为基础，进一步介绍大名鼎鼎的<strong>注意力机制</strong>。</p><span id="more"></span><h1 id="语言模型">语言模型</h1><p>我们首先介绍一下 <strong>语言模型</strong>（LanguageModel）。语言模型是一种通过学习大量文本数据来理解和生成语言的计算模型，其主要功能包括：</p><ul><li><strong>文本生成</strong>：根据给定的上下文生成连贯的文本</li><li><strong>语言理解</strong>：识别和理解句子的结构和含义</li><li><strong>预测下一个词</strong>：基于前面的词预测接下来的词</li></ul><p>语言模型通常使用深度学习中的神经网络来训练，可以应用于各种自然语言处理任务，如：翻译、情感分析、问答系统等。</p><p>2010 年，托马斯·米科洛夫提出了基于 RNN 的语言模型。由于简单 RNN存在梯度消失和梯度爆炸问题，实际应用中更多会使用 LSTM、GRU 等 GatedRNN。</p><p>下图所示，是一个基于 LSTM的简单语言模型，其根据输入单词输出下一个出现的单词的概率分布，并概率性地选择下一个单词。然后，将输出单词继续作为下一个输入单词，自回归地进行输出，从而实现文本自动生成。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/attention-introduction-02.png?x-oss-process=image/resize,w_800" /></p><h1 id="seq2seq-模型">seq2seq 模型</h1><p>除了上述普通的语言模型，我们考虑将一种时序数据转换成另一种时序数据的模型，比如：机器翻译、语音识别等场景，这里我们引入一种基于两个RNN 的模型——seq2seq模型。</p><p>seq2seq 模型，也称为 <strong>Encoder-Decoder</strong> 模型，其中Enoder（编码器）对输入数据进行编码，解码器对已编码的数据进行解码。中间的编码数据本质上就是神经网络的隐藏状态。下图所示，是一个seq2seq 模型的示意图。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/attention-introduction-01.png?x-oss-process=image/resize,w_800" /></p><h2 id="编码器">编码器</h2><p>seq2seq 模型的编码器是一个基于 RNN的简单模型，其层结构如下所示。编码器利用 RNN 将时序数据转换成隐藏状态<span class="math inline">\(h\)</span>。这里的 RNN 是LSTM，不过也可以使用简单 RNN 或 GRU 等。</p><p>编码器的输出向量 <span class="math inline">\(h\)</span> 是 LSTM层最后一个的隐藏状态，其中编码了输入文本的关键信息。这里的重点是，LSTM的隐藏状态 <span class="math inline">\(h\)</span> 是一个<strong>固定长度</strong>的向量。本质而言，编码就是将任意长度的文本转换成一个固定长度的向量。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/attention-introduction-03.png?x-oss-process=image/resize,w_800" /></p><h2 id="解码器">解码器</h2><p>seq2seq 面模型的解码器也是一个基于 RNN的简单模型，如下图所示，其与上一节介绍简单语言模型的神经网络完全相同。唯一的区别在于，LSTM层会接收隐藏向量 <spanclass="math inline">\(h\)</span>。正是这个区别使得普通语言模型变成可以完成翻译功能的解码器。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/attention-introduction-04.png?x-oss-process=image/resize,w_800" /></p><h2 id="应用">应用</h2><p>seq2seq可以将一个时序数据转换成另一个时序数据，这种时序数据转换可以应用在各种任务中，比如：</p><ul><li>机器翻译：将 “一种语言的文本” 转换成 “另一种语言的文本”</li><li>自动摘要：将 “一个长文本” 转换成 “短摘要”</li><li>问答系统：将 “问题” 转换成 “答案”</li><li>自动回复：将 “接收文本” 转换成 “回复文本”</li></ul><p>除了文本之外，seq2seq 还可以处理图像、语音等类型的数据。下图所示是seq2seq 在 <strong>自动图像描述</strong>（ImageCaptioning）中的应用，可以将 “图像” 转换成“文本”。其神经网络结构与上面介绍的 seq2seq 模型的区别在于，编码器从 LSTM转换成 CNN，解码器仍然保持不变。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/attention-introduction-05.png?x-oss-process=image/resize,w_800" /></p><h2 id="问题">问题</h2><p>当然，seq2seq也存在一个很大的问题。编码器输出的是固定长度的向量，这意味着无论输入语句多长，都会被转换成长度固定的向量，如下图所示。很显然，如果文本的信息特别多，固定长度的向量显然是无法承载全部的信息的，如同一个衣柜无法塞下过多的衣服一样。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/attention-introduction-06.png?x-oss-process=image/resize,w_800" /></p><h1 id="注意力机制">注意力机制</h1><p>为了解决 seq2seq 的问题，我们将介绍进一步强化 seq2seq 的<strong>注意力机制</strong>（Attention Machanism，简称Attention）。那么到底什么是注意力机制呢？其核心思想是让模型在处理输入数据时，能够“关注”特定的部分，从而更加有效地捕捉上下文信息。</p><h2 id="编码器优化">编码器优化</h2><p>针对 seq2seq输出的固定长度向量表现力不足的问题，最直观的解决思路就是将编码器 RNN各个时刻输出的隐藏状态 <span class="math inline">\(hs\)</span>全部传递给解码器，从而摆脱一个固定长度向量的表现约束，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/attention-introduction-07.png?x-oss-process=image/resize,w_800" /></p><p>当然，隐藏状态 <span class="math inline">\(hs\)</span>中的每个向量都包含了各个时刻之前的所有信息。在实际使用中，考虑到各个向量的平衡性，编码器使用从两个方向处理时序数据的<strong>双向 RNN</strong> 或 <strong>双向LSTM</strong>，在向量的平衡性上会更加有效。</p><h2 id="解码器优化">解码器优化</h2><p>编码器输出了全部隐藏状态 <spanclass="math inline">\(hs\)</span>，对此，解码器必须要从全部隐藏状态<span class="math inline">\(hs\)</span> 中 “关注”到关键信息，这就是注意力机制的核心目标。</p><p>整体而言，解码器的层结构如下图所示，“某种计算”就是注意力机制的实现层，其接收解码器各个时刻的隐藏层状态和编码器的全部隐藏状态<span class="math inline">\(hs\)</span>，最终输出至 Affine 层。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/attention-introduction-08.png?x-oss-process=image/resize,w_800" /></p><h3 id="实现原理">实现原理</h3><p>从具体实现而言，注意力机制的目标是从编码器隐藏状态 <spanclass="math inline">\(hs\)</span>中提取出与各个输出的单词有对应关系的单词向量。对此，其实现原理也比较简单，<strong>基于编码器的全部隐藏状态<span class="math inline">\(hs\)</span>，结合一个注意力权重 <spanclass="math inline">\(a\)</span>，进行加权求和，得到一个上下文向量 <spanclass="math inline">\(c\)</span></strong>，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/attention-introduction-09.png?x-oss-process=image/resize,w_800" /></p><h4 id="注意力权重">注意力权重</h4><p>首先，我们考虑如何得到注意力权重 <spanclass="math inline">\(c\)</span>。我们的目标是基于解码器的 LSTM层的隐藏状态向量 <spanclass="math inline">\(h\)</span>，判断其与编码器的全部隐藏向量 <spanclass="math inline">\(hs\)</span>中各个单词向量的相似性，从而构建注意力权重矩阵。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/attention-introduction-10.png?x-oss-process=image/resize,w_800" /></p><p>计算向量之间的相似度，这里使用向量内积来表示，下图中使用 dot节点表示。关于内积运算，假如向量 <span class="math inline">\(a = (a_1,a_2, ..., a_n)\)</span> 和向量 <span class="math inline">\(b = (b_1,b_2, ..., b_n)\)</span> 的进行内积运算，其结果如下所示。</p><span class="math display">\[\begin{aligned}a \cdot b = a_1 b_1 + a_2 b_2 + ... + a_n b_n\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/attention-introduction-11.png?x-oss-process=image/resize,w_800" /></p><p>为了方便一次性计算全部隐藏状态 <spanclass="math inline">\(hs\)</span> 的各个向量与 <spanclass="math inline">\(h\)</span> 之间的相似度，这里会对 <spanclass="math inline">\(h\)</span>进行广播，从而得到两个形状完全相同的矩阵。然后通过阿达玛乘积实现两个矩阵各自位置元素进行乘积。最后通过求和运算，对各个向量的阿达玛乘积结果进行求和。本质上就是实现上述的内积运算。下图所示，就是广播后进行阿达玛乘积的过程。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/attention-introduction-15.png?x-oss-process=image/resize,w_800" /></p><p>计算得到初始的注意力权重后，我们还需要对它进行归一化处理，归一化处理一般则都是使用softmax 函数进行处理，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/attention-introduction-12.png?x-oss-process=image/resize,w_800" /></p><p>最后，关于注意力权重的计算，我们可以得到如下所示的计算图。其中包含了阿达玛乘积节点和Sum 节点。Repeat节点主要是为了对矩阵进行广播，以符合矩阵运算的基本要求。注意，计算图中标注的<code>N</code> 是 mini-batch 批量处理的数量。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/attention-introduction-13.png?x-oss-process=image/resize,w_800" /></p><h4 id="加权求和">加权求和</h4><p>然后，我们考虑如何使用注意力权重，结合编码器的全部隐藏状态进行加权求和。这一步运算过程本质上注意力权重计算一样，只不过少了归一化处理。下图所示是基于全部隐藏状态<span class="math inline">\(hs\)</span> 和注意力矩阵 <spanclass="math inline">\(a\)</span> 进行加权求和的计算图。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/attention-introduction-14.png?x-oss-process=image/resize,w_800" /></p><h3 id="整体架构">整体架构</h3><p>我们可以将注意力权重、加权求和两个部分整体转换成如下所示的计算图。两者分别对应Attention Weight 层和 Weight Sum 层，对外可以整体表示为 Attention层。Attention 层以编码器的全部隐藏状态 <spanclass="math inline">\(hs\)</span> 和解码器当前时刻的 LSTM 输出 <spanclass="math inline">\(h\)</span> 作为输入，最终输出上下文向量 <spanclass="math inline">\(c\)</span>。这就是注意力机制的技术核心。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/attention-introduction-16.png?x-oss-process=image/resize,w_800" /></p><p>我们将 Attention层重新加入解码器的层结构，可以得到如下所所示的层结构图。这里要注意的是，我们不仅将Attention 层的输出的上下文向量输入至 Affine 层，还将 LSTM层的隐藏状态也输入至 Affine 层。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/attention-introduction-17.png?x-oss-process=image/resize,w_800" /></p><h1 id="注意力机制的应用">注意力机制的应用</h1><p>在了解了注意力机制之后，我们再来看看 Attention 的相关应用。</p><h2 id="基于-attention-的-seq2seq">基于 Attention 的 seq2seq</h2><p>首先，我们可以对 seq2seq 模型进行优化。基于 Attention的优化主要集中在解码器部分，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/attention-introduction-18.png?x-oss-process=image/resize,w_800" /></p><h2 id="gnmt">GNMT</h2><p>在机器翻译领域，经历过 <strong>基于规则的翻译</strong> 到<strong>基于用例的翻译</strong>，再到<strong>基于统计的翻译</strong>，以及如今的<strong>神经机器翻译</strong>（Neural Machine Translation）。</p><p>谷歌翻译，全称谷歌神经机器翻译系统（Google，Neural MachineTranslation，GNMT）正是一种基于 Attention 的 seq2seq模型，其由编码器、解码器、Attention构成。当然，与我们介绍的简单模型不同，其为了提供精度做了更多的优化，比如：LSTM层的多层化、双向 LSTM、skip connection 等，以及为了提高学习速度，支持多GPU 的分布式学习，其层结构如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/attention-introduction-19.png?x-oss-process=image/resize,w_800" /></p><h2 id="transformer">Transformer</h2><p>最后，我们来介绍一下著名的 Transformer。</p><p>由于 RNN必须基于上一时刻的计算结果进行迭代，因此难以在时间方向上进行并行计算。对此，在使用了GPU的并行计算环境下具有严重的性能瓶颈。于是，研究者提出了一个著名的模型——Transformer。Transformer不用 RNN，而用 Attention 进行处理，其中使用了<strong>自注意力</strong>（Self-Attention）技术。</p><p>自注意力机制是一种特殊的注意力机制，主要用于同一序列内的元素之间的关系建模，其允许模型在处理一个序列时，动态地关注该序列的其他部分。在结构上，自注意力的特点是两个输入都是同一个时序数据，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/attention-introduction-20.png?x-oss-process=image/resize,w_800" /></p><p>下面，我们再来看下 Transformer 的简化层结构，如下图所示。Transformer中使用 Attention 代替了 RNN，并且编码器和解码器都使用了Self-Attention。图中 Feed Forward层便是前馈神经网络，具体而言是具有一个隐藏层、激活函数为 ReLU的全连接神经网络。<span class="math inline">\(N_x\)</span>表示灰色背景包围的元素被堆叠了 N 次。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/attention-introduction-21.png?x-oss-process=image/resize,w_800" /></p><h1 id="总结">总结</h1><p>本文我们主要介绍了 seq2seq模型，其主要由编码器和解码器两部分构成，内部使用了 RNN层结构。但是，seq2seq存在一个问题，即编码器输出固定长度的向量存在一定的局限性。当输入时序数据过长时，固定长度的向量无法承载全部信息。</p><p>为了解决 seq2seq的问题，我们自然而言地想出使用编码器全部隐藏状态作为解码器的输入。为了让解码器能从全部隐藏状态中关注到关键信息，我们引出了注意力机制。注意力机制的核心思想是基于编码器的全部隐藏状态，结合注意力权重，进行加权求和，得到一个上下文向量。</p><p>最后，我们介绍了注意力机制的几种应用，分别是：基于 Attention 的seq2seq、GMNT、Transformer。在 Transformer中，其应用了注意力机制的一种特殊实现——自注意力。</p><h1 id="参考">参考</h1><ol type="1"><li>《深度学习进阶：自然语言处理》</li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;前一篇文章 &lt;a
href=&quot;https://chuquan.me/2024/10/26/rnn-introduction/&quot;&gt;循环神经网络&lt;/a&gt;
中我们介绍了 RNN、LSTM、GRU
等。本文，我们将以此为基础，进一步介绍大名鼎鼎的
&lt;strong&gt;注意力机制&lt;/strong&gt;。&lt;/p&gt;</summary>
    
    
    
    <category term="机器学习" scheme="http://chuquan.me/categories/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0/"/>
    
    
    <category term="seq2seq" scheme="http://chuquan.me/tags/seq2seq/"/>
    
    <category term="Attention" scheme="http://chuquan.me/tags/Attention/"/>
    
    <category term="Self-Attention" scheme="http://chuquan.me/tags/Self-Attention/"/>
    
    <category term="Transformer" scheme="http://chuquan.me/tags/Transformer/"/>
    
  </entry>
  
  <entry>
    <title>循环神经网络</title>
    <link href="http://chuquan.me/2024/10/26/rnn-introduction/"/>
    <id>http://chuquan.me/2024/10/26/rnn-introduction/</id>
    <published>2024-10-26T11:02:22.000Z</published>
    <updated>2024-10-26T11:08:23.146Z</updated>
    
    <content type="html"><![CDATA[<p>前面，我们介绍了适用于图像和视觉领域的 <ahref="https://chuquan.me/2024/09/28/cnn-introduction/">卷积神经网络</a>。本文，我们来介绍适用于时序数据处理场景的<strong>循环神经网络</strong>（Recurrent Neural Network，RNN）。</p><span id="more"></span><h1 id="前馈神经网络的局限性">前馈神经网络的局限性</h1><p>我们首先来介绍一下前馈神经网络的局限性。什么是前馈神经网络？其特征是数据在神经网络中单向流动，从输入层经过隐藏层达到输出层，中间不存在反馈连接。前面我们介绍的入门级神经网络、卷积神经网络都属于前馈神经网络。</p><p>前馈神经网络的一个缺点是无法处理时序数据，通常只适用于一次输入，一次输出的场景。根本原因在于其不存在记忆能力，类似函数式编程中的纯函数，其内部不存在状态，当输入相同时，则输出也相同。</p><h1 id="循环神经网络">循环神经网络</h1><p>循环神经网络解决了前馈神经网络无法解决的问题，经过训练后可以处理时序数据。时序数据是指具有顺序或时间关联性的数据，比如：视频流、音频流、构成文章或句子的单词序列等。因此，RNN非常适合语言翻译、语音识别、图像字幕、自然语言处理等场景。</p><p>类似于 CNN 包含卷积层、池化层，RNN 的特征是包含一个循环层（RecurrentLayer），不过大多数情况下称为 “RNN 层”。下面，我们来重点介绍一下 RNN层的工作原理。</p><h1 id="rnn-与-time-rnn">RNN 与 Time RNN</h1><p>下图所示，是 RNN层的两种数据流向示意图。经典的表示法采用水平流向表示，为了方便分析，后续我们将使用垂直流向表示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-01.png?x-oss-process=image/resize,w_800" /></p><p>RNN 层内部具有环路，数据在层内循环。下标 <spanclass="math inline">\(t\)</span> 表示时间，当时序数据 <spanclass="math inline">\((x_0, x_1, ..., x_t, ...)\)</span> 输入到 RNN层后，将输出 <span class="math inline">\((h_0, h_1, ..., h_t,...)\)</span> 等 <strong>隐藏状态</strong>（Hidden State）。</p><p>为了进一步分析环路的数据走向，我们对 RNN层进行循环展开，如下所示。可以看出，各个时刻的 RNN层接收两个输入，分别是：</p><ul><li><strong>当前时刻的时序数据输入</strong></li><li><strong>前一时刻的隐藏状态输出</strong></li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-02.png?x-oss-process=image/resize,w_800" /></p><p>很显然，循环展开后包含了各个时刻的 RNN 层。为了区分，我们重新定义 RNN的不同结构。</p><ul><li><strong>RNN 层</strong>：表示单一时刻的 RNN 层</li><li><strong>Time RNN 层</strong>：表示由多个单一时刻 RNN 层所构成的 RNN层</li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-08.png?x-oss-process=image/resize,w_800" /></p><h2 id="正向传播">正向传播</h2><p>我们先来看下 RNN层的正向传播计算图，如下所示。其内部包含三种运算，分别是：</p><ul><li>矩阵乘法：MatMul 节点</li><li>矩阵加法：Sum 节点</li><li>激活函数：tanh 节点，即双曲正切函数</li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-03.png?x-oss-process=image/resize,w_800" /></p><p>可以看出，其内部包含两个权重，分别是将输入 <spanclass="math inline">\(x\)</span> 转化为输出 <spanclass="math inline">\(h\)</span> 的权重 <spanclass="math inline">\(W_x\)</span>和将前一时刻的输出转换为当前时刻的输出的权重 <spanclass="math inline">\(W_h\)</span>。此外，还有偏置 <spanclass="math inline">\(b\)</span>。这里的 <spanclass="math inline">\(h_{t-1}\)</span> 和 <spanclass="math inline">\(x_t\)</span>都是行向量。根据计算图，我们最终可以得出如下的计算公式。</p><span class="math display">\[\begin{aligned}h_t = tanh(h_{t-1}W_h + x_t W_x + b)\end{aligned}\]</span><p>由此，我们可以认为 RNN 具有 “状态” <spanclass="math inline">\(h\)</span>，并基于此进行运算，这也是 RNN 层是“具有状态的层” 或 “具有记忆的层” 的原因。</p><p>相比 RNN 层，Time RNN 层的正向传播在输入和输出侧有所不同。Time RNN 将<span class="math inline">\((x_0, x_1, ..., x_{T-1})\)</span> 捆绑为<span class="math inline">\(xs\)</span> 作为输入，将 <spanclass="math inline">\((h_0, h_1, ..., h_{T-1})\)</span> 捆绑为 <spanclass="math inline">\(hs\)</span> 作为输出，内部则是由多个 RNN层连接而成。</p><h2 id="反向传播">反向传播</h2><p>下图所示为 RNN 层的反向传播计算图。结合 <ahref="https://chuquan.me/2024/09/21/deep-learning-layer/">《神经网络的分层设计原理》</a>中基于计算图计算反向传播梯度的方法，我们可以很容易求解 RNN层的反向传播梯度。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-07.png?x-oss-process=image/resize,w_800" /></p><p>下图所示为 Time RNN 层的反向传播计算图。上游的梯度记为 <spanclass="math inline">\(dhs\)</span>，下游的梯度记为 <spanclass="math inline">\(dxs\)</span>。这是 Time RNN层作为整体在外部的反向传播路径。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-09.png?x-oss-process=image/resize,w_800" /></p><p>关于 Time RNN 层内部各个时刻的 RNN层的反向传播路径，具体方法有两种，分别是：</p><ul><li><strong>基于时间的反向传播</strong>（Backpropagation ThroughTime，BPTT），简称 BPTT</li><li>基于 BPTT 进行分段截断，简称 Truncated BPTT</li></ul><h3 id="bptt">BPTT</h3><p>BPTT 的核心思想很简单，对 Time RNN层进行循环展开，由此求各个时刻的目标梯度，即按时间顺序展开的神经网络的误差传播法。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-04.png?x-oss-process=image/resize,w_800" /></p><p>随着时序数据的时间跨度增大，BPTT 反向传播会出现以下几个问题：</p><ul><li>内存成比例增大，因为各个时刻的 RNN 层会存储中间数据。</li><li>时间成比例变长，因为误差（梯度）会按顺序反向传播。</li><li>梯度逐渐不稳定，比如：梯度爆炸、梯度消失。</li></ul><h3 id="truncated-bptt">Truncated BPTT</h3><p>为了解决反向传播的长时序数据问题，一种自然而然的方法是 <strong>将Time RNN 层循环展开后的反向传播路径进行分段截断</strong>，这就是Truncated BPTT的核心思想。这里要注意的是，我们只截断反向传播，而不截断正向传播，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-05.png?x-oss-process=image/resize,w_800" /></p><p>下图所示是 Truncated BPTT的训练示意图。我们来看一下它是如何对时序数据过大产生的问题进行优化的。</p><ul><li>对于时间问题，由于反向传播依赖正向传播的中间数据，即使是 TruncatedBPTT，也需要按序的正向传播。只不过对于反向传播，每个分段可以并行处理。从而在一定程度上提高训练速度。</li><li>对于梯度问题，截断后也能够在一定程度上缓解梯度不稳定的情况，前提是要选择合适的截断长度。</li><li>对于内存问题，此时我们无需积累完整路径的所有中间数据后才进行反向传播，因此也有了一定程度的内存优化。</li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-06.png?x-oss-process=image/resize,w_800" /></p><h1 id="rnn-语言模型">RNN 语言模型</h1><p>RNN 语言模型（Language Model），简称 RNNLM，是一个基于 RNN层实现的神经网络，用于理解和生成自然语言文本。</p><p>我们考虑一个问题：当输入的内容为 “Tom was watching TV in his room.Mary came into the room. Mary said hi to _”时，如何预测最后一个单词是什么？很显然，最后一个单词应该是“Tom”。但是，由于 “Tom”单词相距预测点太远，常规的神经网络无法有效地进行预测。对此，基于 RNN的语言模型 RNNLM 可以有效解决这个问题。</p><p>下图所示是一个简单的 RNNLM 的神经网络模型。每一个 RNN层的上下游都有对应的神经网络层，比如：Embedding、Affine、Softmax等，用于处理单一时刻的时序数据。类似于 RNN 和 Time RNN的关系，这里我们将整体处理含有 <span class="math inline">\(T\)</span>个时序数据的层称为 “Time XX 层”。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-10.png?x-oss-process=image/resize,w_800" /></p><p>对于学习阶段，RNNLM 中也有对应的 Time Softmax with Loss层，如下所示。<span class="math inline">\(x_0\)</span>、<spanclass="math inline">\(x_1\)</span> 等数据是推理输出的得分，<spanclass="math inline">\(t_0\)</span>、<spanclass="math inline">\(t_1\)</span> 等数据是正确解标签。<spanclass="math inline">\(T\)</span> 个 Softmax with Loss层各自计算损失，求和取平均值，作为最终的损失。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-11.png?x-oss-process=image/resize,w_800" /></p><h1 id="梯度爆炸和梯度消失">梯度爆炸和梯度消失</h1><p>前面我们提到 RNN在学习时序数据的长期依赖时，会存在梯度爆炸和梯度消失的问题。这里我们来介绍一下深层次的原因。</p><p>如下图所示，我们仅关注 Time RNN 层中的梯度传播。这里考虑长度为 <spanclass="math inline">\(T\)</span>的时序数据，在反向传播过程中，梯度会反复经历多次 tanh、sum、MatMul的运算：</p><ul><li>sum：加法的导数是常数 1</li><li>tanh：当 <span class="math inline">\(y = tanh(x)\)</span>时，其导数为 <span class="math inline">\(\frac{dy}{dx} = 1 -y^2\)</span></li><li>MatMul：矩阵乘法的导数计算可以参考 <ahref="https://chuquan.me/2024/09/21/deep-learning-layer/#matmul-%E8%8A%82%E7%82%B9">《神经网络的分层设计原理》</a></li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-12.png?x-oss-process=image/resize,w_800" /></p><p>首先，我们来看 tanh 对于反向传播梯度的影响。下图所示为 tanh及其导数的曲线图，导数值的范围为 [0, 1]。当多次经过 tanh节点时，梯度值会不断进行乘积，会逐渐减小，趋近于0。这正是梯度消失的原因之一。这里主要是因为激活函数 tanh导致的，如果将其改为 ReLU，可以有效抑制梯度消失的问题。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-13.png?x-oss-process=image/resize,w_800" /></p><p>然后，我们考虑 MatMul 对于反向传播梯度的影响。简单起见，我们忽略 tanh节点的影响。下图所示为 MatMul 的反向传播计算图，其中 <spanclass="math inline">\(W_h\)</span> 保持不变，当时序数据越长，<spanclass="math inline">\(W_h^T\)</span> 的乘积次数越多。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-14.png?x-oss-process=image/resize,w_800" /></p><p>由此可以看出梯度爆炸或者梯度消失，取决于矩阵，更专业的术语是矩阵奇异值。矩阵奇异值本质上表示数据的离散程度，根据奇异值（更准确地说是多个奇异值中的最大值）是否大于1。如果奇异值的最大值大于1，可以预测梯度很有可能会呈指数级增加，即梯度爆炸；如果奇异值的最大值小于1，可以预测梯度会呈指数级减少，即梯度消失。当然，并不是说奇异值比 1大就一定会出现梯度爆炸。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-15.png?x-oss-process=image/resize,w_800" /></p><h1 id="梯度爆炸优化">梯度爆炸优化</h1><p><strong>梯度裁剪</strong>（GradientClipping）可以用于解决梯度爆炸，其基本思想可以用如下所示的伪代码表示。</p><span class="math display">\[\begin{aligned}if \space \space \space || \hat g || \geq &amp; \space threshold\\\hat g = &amp; \space \frac{threshold}{|| \hat g ||} \hat g\end{aligned}\]</span><p>这里将神经网络所有参数的梯度整合成一个，用符合 <spanclass="math inline">\(\hat g\)</span> 表示。<spanclass="math inline">\(threshold\)</span> 表示阈值。当 <spanclass="math inline">\(\hat g\)</span> 大于或等于阈值，则对 <spanclass="math inline">\(\hat g\)</span> 进行裁剪。</p><h1 id="梯度消失优化">梯度消失优化</h1><p><strong>Gated RNN</strong> 可以用于解决梯度消失，其从根本上改变了 RNN结构。目前业界已经出现了诸多 Gated RNN 网络结构，其中具有代表性的有 LSTM和 GRU。下面，我们分别来进行介绍。</p><h2 id="lstm">LSTM</h2><p>LSTM（Long Short-Term Memory），长短期记忆网络。类似于 RNN 和 TimeRNN，这里也存在 LSTM 和 Time LSTM 的区别。这里，我们主要介绍单个时刻的LSTM 结构。</p><p>如下所示，LSTM 与 RNN 的区别在于 LSTM 具有额外的记忆单元 <spanclass="math inline">\(c\)</span>，其只存在于 Time LSTM内部，不会其它层进行传输。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-16.png?x-oss-process=image/resize,w_800" /></p><p>LSTM内部包含多种结构，包括：输出门、遗忘门、记忆单元、输入门等，下面我们先从LSTM 的基本结构进行介绍。</p><h3 id="基本结构">基本结构</h3><p>LSTM 中的记忆单元 <span class="math inline">\(c_t\)</span>存储了过去到时刻 <span class="math inline">\(t\)</span>的所有记忆，并基于此向外部层和下一时刻的 LSTM 输出隐藏状态 <spanclass="math inline">\(h_t\)</span>。</p><p>下图所示，LSTM 中当前记忆单元 <spanclass="math inline">\(c_t\)</span> 基于 3 个输入 <spanclass="math inline">\(c_{t-1}\)</span>、<spanclass="math inline">\(h_{t-1}\)</span>、<spanclass="math inline">\(x_t\)</span>经过“某种计算”得出。最后输出的隐藏状态 <spanclass="math inline">\(h_t\)</span> 是在 <spanclass="math inline">\(c_t\)</span> 的基础上应用 tanh函数计算得到的。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-17.png?x-oss-process=image/resize,w_800" /></p><p>这里提到的“某种计算”是下面要介绍的各种运算，它们都会涉及到一个概念——<strong>门</strong>（Gate）。门的本质就是阀门，可以控制流过数据的大小。在神经网络中，激活函数的概念与它非常相似。因此，LSTM使用经典的激活函数 <code>sigmoid</code> 作为各种门结构，后续将使用 <spanclass="math inline">\(\sigma\)</span> 符号进行表示。</p><h3 id="输出门">输出门</h3><p>首先，LSTM 增加了 <strong>输出门</strong>（Output Gate）。输入 <spanclass="math inline">\(x_t\)</span> 有权重 <spanclass="math inline">\(W_x\)</span>，输入隐藏状态 <spanclass="math inline">\(h_{t-1}\)</span> 有权重 <spanclass="math inline">\(W_h\)</span>。将它们的矩阵乘积和偏置 <spanclass="math inline">\(b\)</span> 之和传入 <spanclass="math inline">\(sigmoid\)</span> 函数，结果就是输门的输出值 <spanclass="math inline">\(o\)</span>。</p><span class="math display">\[\begin{aligned}o = \sigma (x_t W_x + h_{t-1} W_h + b)\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-18.png?x-oss-process=image/resize,w_800" /></p><p>输出门的输出值 <span class="math inline">\(o\)</span> 进一步与 <spanclass="math inline">\(tanh(c_t)\)</span>进行乘积。注意，这里的乘积并不是矩阵乘积，而是<strong>阿达玛乘积</strong>，即矩阵对应元素之间的乘积。如果使用 <spanclass="math inline">\(\circ\)</span> 表示阿达玛乘积，其计算式如下。</p><span class="math display">\[\begin{aligned}h_t = o \circ tanh(c_t)\end{aligned}\]</span><h3 id="遗忘门">遗忘门</h3><p>为了模拟更真实的记忆效果，LSTM 加入了 <strong>遗忘门</strong>（ForgetGate），其计算图如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-19.png?x-oss-process=image/resize,w_800" /></p><p>遗忘门的计算与输出门类似，区别在于两者在计算图中的位置不同，其计算式如下。</p><span class="math display">\[\begin{aligned}f = \sigma (x_t W_x + h_{t-1} W_h + b)\end{aligned}\]</span><h3 id="记忆单元优化">记忆单元优化</h3><p>如果针对记忆单元，我们只增加遗忘门，那么会导致记忆逐步遗忘。因此，我们还要增加强化记忆的结构，由此对记忆单元进行优化。这里，我们增加tanh 节点，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-20.png?x-oss-process=image/resize,w_800" /></p><p>tanh 节点计算与其他门的计算类似，如下所示。</p><span class="math display">\[\begin{aligned}g = tanh(x_t W_x + h_{t-1} W_h + b)\end{aligned}\]</span><p>基于 tanh 节点计算得到的结果 <span class="math inline">\(g\)</span>最终与加到上上一时刻的记忆单元 <spanclass="math inline">\(c_{t-1}\)</span> 中，从而形成新的记忆。</p><h3 id="输入门">输入门</h3><p>输入门判断新增信息 <span class="math inline">\(g\)</span>的各个元素的价值，会对待添加的信息进行取舍，其计算图如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-21.png?x-oss-process=image/resize,w_800" /></p><p>输入门的输出值计算如下所示。</p><span class="math display">\[\begin{aligned}i = \sigma (x_t W_x + h_{t-1} W_h + b)\end{aligned}\]</span><h3 id="完整结构">完整结构</h3><p>最后，我们可以得到如下所示的 LSTM 完整结构，可以看出 LSTM 是在 RNN的基础上增加了一系列门结构以及相关运算实现的。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-22.png?x-oss-process=image/resize,w_800" /></p><h3 id="反向传播-1">反向传播</h3><p>接下来，我们来看一下为什么 LSTM能够解决梯度消失的问题。其原因可以通过观察记忆单元 <spanclass="math inline">\(c\)</span> 的反向传播来了解，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-23.png?x-oss-process=image/resize,w_800" /></p><p>记忆单元的反向传播会反复经过 “+” 和 “×” 节点。“+” 节点的导数是1，会直接传递上游梯度至下游。“×”节点并不是矩阵乘积，而是阿达玛乘积。阿达玛乘积是矩阵中对应元素的乘积运算，而且每次都会基于不同的门值进行乘积运算，因此不会发生梯度消失或梯度爆炸。</p><p>在反向传播过程中，遗忘门认为应该忘记的记忆单元元素，其梯度会变小；不能忘记的记忆单元元素，其梯度并不会退化。因此，可以期待记忆单元能够保存学习长期的依赖关系。</p><h2 id="gru">GRU</h2><p>GRU（Gated Recurrent Unit），门控循环单元，其继承了 LSTM的思路，但是减少了参数，缩短了计算时间。</p><p>GRU 的计算图和计算式如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rnn-introduction-24.png?x-oss-process=image/resize,w_800" /></p><span class="math display">\[\begin{aligned}z = &amp; \space \sigma (x_t W_x + h_{t-1} W_h + b)\\r = &amp; \space \sigma (x_t W_x + h_{t-1} W_h + b)\\\widetilde{h} = &amp; \space tanh (x_t W_x + h_{t-1} W_h + b)\\h_t = &amp; \space (1 - z) \circ h_{t-1} + z \circ \widetilde{h}\end{aligned}\]</span><p>GRU 没有记忆单元，只有一个隐藏状态 <spanclass="math inline">\(h\)</span>在时间方向上传播。其使用了两个门结构：<spanclass="math inline">\(r\)</span>（reset 门）、<spanclass="math inline">\(z\)</span>（update 门）。</p><p><span class="math inline">\(r\)</span>决定在多大程度上“忽略”过去的隐藏状态。如果 <spanclass="math inline">\(r\)</span> 为 0，则新的隐藏状态 <spanclass="math inline">\(\widetiled{h}\)</span> 只取决于 <spanclass="math inline">\(x_t\)</span>，即过去的隐藏状态被忽略。</p><p><span class="math inline">\(z\)</span>是更新隐藏状态的门，其包含了LSTM 中遗忘门和输入门的作用。<spanclass="math inline">\(\widetilde{h}\)</span> 中的 <spanclass="math inline">\((1-z)\circ h_{t-1}\)</span>表示遗忘门的功能；<span class="math inline">\(z \circ\widetilde{h}\)</span> 表示输入门的功能。</p><p>整体而言，GRU 是简化版的 LSTM 架构，相比之下，GRU的参数和计算成本更少。关于 GRU 和 LSTM的选择，根据不同的任务和超参数的设置，结论可能不同。由于 GRU超参数少、计算量小，因此比较适合于数据集较小，设计模型需要反复实验的场景。</p><h1 id="总结">总结</h1><p>本文我们主要介绍了循环神经网络的结构，其内部根据时序数据可以展开成多个循环结构。为了区分，我们将整体称为Time RNN，将内部的单个循环结构称为 RNN。</p><p>Time RNN 的反向传播主要有两种方法，分别是：BPTT 和 TruncatedBPTT。后者是在前者的基础上对反向传播路径进行分段截断。从而在一定程度上缓解时序数据过长带来的时间问题、梯度问题、内存问题。</p><p>之后，我们进一步介绍了梯度问题中，梯度爆炸和梯度消失出现的原因。对于梯度爆炸，我们介绍了梯度裁剪的方法。对于梯度消失，我们介绍了Gate RNN 的两种结构：LSTM 和 GRU。</p><h1 id="参考">参考</h1><ol type="1"><li>《深度学习进阶：自然语言处理》</li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;前面，我们介绍了适用于图像和视觉领域的 &lt;a
href=&quot;https://chuquan.me/2024/09/28/cnn-introduction/&quot;&gt;卷积神经网络&lt;/a&gt;。本文，我们来介绍适用于时序数据处理场景的
&lt;strong&gt;循环神经网络&lt;/strong&gt;（Recurrent Neural Network，RNN）。&lt;/p&gt;</summary>
    
    
    
    <category term="机器学习" scheme="http://chuquan.me/categories/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0/"/>
    
    
    <category term="RNN" scheme="http://chuquan.me/tags/RNN/"/>
    
    <category term="LSTM" scheme="http://chuquan.me/tags/LSTM/"/>
    
    <category term="Gated RNN" scheme="http://chuquan.me/tags/Gated-RNN/"/>
    
    <category term="GRU" scheme="http://chuquan.me/tags/GRU/"/>
    
    <category term="Time RNN" scheme="http://chuquan.me/tags/Time-RNN/"/>
    
    <category term="BPTT" scheme="http://chuquan.me/tags/BPTT/"/>
    
    <category term="Truncated BPTT" scheme="http://chuquan.me/tags/Truncated-BPTT/"/>
    
  </entry>
  
  <entry>
    <title>自然语言处理中的单词含义表示</title>
    <link href="http://chuquan.me/2024/10/15/nlp-word-representation/"/>
    <id>http://chuquan.me/2024/10/15/nlp-word-representation/</id>
    <published>2024-10-15T01:31:01.000Z</published>
    <updated>2024-10-15T01:34:17.411Z</updated>
    
    <content type="html"><![CDATA[<p>我们日常使用的语言，如中文、英文，称为<strong>自然语言</strong>（natural language）。<strong>自然语言处理</strong>（Natural LanguageProcessing，NLP）则是一种让计算机理解人类语言的技术。现在很多知名的工具，比如：搜索引擎、机器翻译、ChatGPT等，都是基于自然语言处理技术的应用。</p><span id="more"></span><h1 id="文本分词">文本分词</h1><p>大多数情况下，NLP处理的是由大量单词组成的文本。因此，这里面临的第一个问题是如何进行分词。</p><p>英文的分词比较简单，每个单词都是独立的，可以通过空、标点符号、正则表达式等进行分词。相比而言，中文的分词会显得比较复杂。对此，研究人员针对不同语言开发了各种不同的分词工具，比如：</p><ul><li>英文分词可以使用 NLTK、spaCy 等工具</li><li>中文分词可以使用 jieba、THULAC 等工具</li></ul><p>一些知名的预训练模型 BERT 和 GPT各自拥有专属的分词器（Tokenizer），比如：WordPiece、Byte PairEncoding（BPE）等。</p><h1 id="单词含义">单词含义</h1><p>自然语言的灵活性非常强，同一个单词在不同的语境下，不同的上下文中具有不同的含义，这与编程语言截然不同。因此，理解单词的含义成为了NLP 的核心问题之一，也是本文将重点讨论的话题。</p><p>在 NLP 技术的发展过程中，单词含义的表示方法主要经历了一下几种：</p><ul><li>基于同义词词典的方法</li><li>基于计数的方法</li><li>基于推理的方法</li></ul><p>下面，我们分别来介绍这几种单词含义的表示方法。</p><h1 id="同义词词典法">同义词词典法</h1><p>基于 <strong>同义词词典</strong>（Thesaurus）的方法的核心思路是：</p><ul><li>将含义相同或含义类似的单词归类到同一个组中</li><li>同时定义单词之间细粒度的层级关系，比如：上位与下位的关系，整体与局部的关系。</li></ul><p>如下所示的例子是一个含义组别中，各个单词之间的关系，使用图进行表示。其中，motorvehicle 和 car 是上位和下位的关系。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-01.png?x-oss-process=image/resize,w_800" /></p><p>在 NLP 领域中，最著名的同义词词典是普林斯顿大学开发的 <ahref="https://wordnet.princeton.edu/">WordNet</a>，其至今被应用于各种自然语言处理应用中。</p><h2 id="缺陷">缺陷</h2><p>首先，同义词词典是一个人工标记的词典，制作词典会耗费巨大的人力成本。</p><p>其次，同义词必须要随着语言的发展而更新。一方面，语言会出现新的单词，比如：团购、众筹等；另一方面，语言的含义也会发生变化，比如：卧龙凤雏，其含义从往年的褒义词变成了如今的贬义词。</p><p>然而最致命的缺陷还是同义词词典<strong>无法表示单词含义的微妙差异</strong>。各种语言中都存在单词含义相同，但是用法不同的情况，而这种细微的差别在同义词词典中是无法表示出来的。</p><h1 id="计数法">计数法</h1><p>计数法，也称为统计法，其核心思想是<strong>基于语料库（Corpus）中的大量文本数据，自动且高效地提取单词的本质信息</strong>。</p><h2 id="语料库预处理">语料库预处理</h2><p>语料库本质上就是文本数据而已，比如：Wikipedia、GoogleNews、文学作品集等。</p><p>在使用语料库之前，必须先进行预处理，预处理主要有以下几个步骤：</p><ul><li>对文本进行分词</li><li>为每个单词创建唯一的 ID</li><li>基于语料库创建（ID，单词）和（单词，ID）的映射表，便于后续进行查找</li></ul><p>如下所示，我们对一个简单的语料库进行预处理，分别得到<code>id_to_word</code> 和 <code>word_to_id</code> 两个映射表。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">&gt;&gt;&gt; </span>text</span><br><span class="line"><span class="string">&#x27;you say goodbye and i say hello .&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="meta">&gt;&gt;&gt; </span>words</span><br><span class="line">[<span class="string">&#x27;you&#x27;</span>, <span class="string">&#x27;say&#x27;</span>, <span class="string">&#x27;goodbye&#x27;</span>, <span class="string">&#x27;and&#x27;</span>, <span class="string">&#x27;i&#x27;</span>, <span class="string">&#x27;say&#x27;</span>, <span class="string">&#x27;hello&#x27;</span>, <span class="string">&#x27;.&#x27;</span>]</span><br><span class="line"></span><br><span class="line"><span class="meta">&gt;&gt;&gt; </span>id_to_word</span><br><span class="line">&#123;<span class="number">0</span>: <span class="string">&#x27;you&#x27;</span>, <span class="number">1</span>: <span class="string">&#x27;say&#x27;</span>, <span class="number">2</span>: <span class="string">&#x27;goodbye&#x27;</span>, <span class="number">3</span>: <span class="string">&#x27;and&#x27;</span>, <span class="number">4</span>: <span class="string">&#x27;i&#x27;</span>, <span class="number">5</span>: <span class="string">&#x27;hello&#x27;</span>, <span class="number">6</span>:<span class="string">&#x27;.&#x27;</span>&#125;</span><br><span class="line"></span><br><span class="line"><span class="meta">&gt;&gt;&gt; </span>word_to_id</span><br><span class="line">&#123;<span class="string">&#x27;you&#x27;</span>: <span class="number">0</span>, <span class="string">&#x27;say&#x27;</span>: <span class="number">1</span>, <span class="string">&#x27;goodbye&#x27;</span>: <span class="number">2</span>, <span class="string">&#x27;and&#x27;</span>: <span class="number">3</span>, <span class="string">&#x27;i&#x27;</span>: <span class="number">4</span>, <span class="string">&#x27;hello&#x27;</span>: <span class="number">5</span>, <span class="string">&#x27;.&#x27;</span>: <span class="number">6</span>&#125;</span><br></pre></td></tr></table></figure><h2 id="分布式表示">分布式表示</h2><p>在 NLP 中，广泛使用 <strong>分布式表示</strong>（DistributedRepresentation）来表示单词含义。分布式表示将单词表示为<strong>固定长度的向量</strong>，其采用密集向量表示，即向量的大多数元素是由非0 实数表示的。比如，一个三维分布式表示<code>[0.21, -0.45, 0.83]</code>。</p><p>为什么使用向量来表示？因为对于向量，我们可以使用<strong>余弦相似度</strong>（CosineSimilarity）来计算两个向量之间的夹角余弦值，余弦值越接近1，表示向量夹角越小，说明相似度越高。使用向量表示单词，那么就可以计算不同单词含义之间的相似度。</p><p>假设，有两个向量 <span class="math inline">\(x = (x_1, x_2, x_3, ...,x_n)\)</span> 和 <span class="math inline">\(y = (y_1, y_2, y_3, ...,y_n)\)</span>，那么余弦相似度的定义如下。为了避免分母为0，一般会给分母加上一个微小值 eps，默认其值为 0.00000001。</p><span class="math display">\[\begin{aligned}similarity(x, y) = \frac{x \cdot y}{ ||x|| \space ||y|| }=  \frac{x_1 y_1 + ... + x_n y_n}{\sqrt{x_1^2 + ... + x_n^2} \sqrt{y_1^2+ ... + y_n^2}}\end{aligned}\]</span><h2 id="分布式假设">分布式假设</h2><p>在 NLP 中，很多研究都是基于<strong>分布式假设</strong>（DistributionalHypothesis）完成的。分布式假设认为，<strong>一个单词的含义是由其周围的单词形成的</strong>。单词本身没有含义，其含义是由上下文（语境）形成的。相同的单词经常出现在相同的语境中，比如："Idrink beer." 和 "I drink wine."，drink 附近经常会出现饮料，beer 和 wine则是含义相似的单词。</p><p>这里提到的“上下文”，指的是某个单词周围的单词。我们将上下文的大小称为<strong>窗口大小</strong>（Window Size）。假如，窗口大小为2，则表示上下文包含了目标单词前后各两个单词。如下所示，是一个窗口大小为2 的示例。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-02.png?x-oss-process=image/resize,w_800" /></p><h2 id="词嵌入">词嵌入</h2><p><strong>词嵌入</strong>（Word Embedding）表示<strong>将单词映射到向量中间的过程或方法</strong>，也可以认为是<strong>基于分布式假设将单词转换成分布式表示的过程或方法</strong>。</p><p>计数法的词嵌入，可以分为以下几个步骤：</p><ul><li>首先，进行语料库预处理，得到每个单词的 ID 以及两个映射表</li><li>其次，定义每个单词的向量维度为语料库中不同单词的数量</li><li>然后，定义上下文大小，即窗口大小</li><li>最后，根据窗口大小，为每个单词统计其上下文的单词数量，并记录在向量中指定位置（各个单词ID 指定的位置）</li></ul><p>假设，我们以 “you say goodby and i say hello.”作为语料库，定义窗口大小为 1。由于语料库中总共有 7个不同的单词，包括句号，因此单词的向量维度等于 7。</p><p>首先，我们来看 "you" 的向量表示，其上下文只有一个单词"say"，那么我们可以将单词 "say" 的 ID 对应在向量中的位置进行计数，值为1，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-03.png?x-oss-process=image/resize,w_800" /></p><p>然后，我们来看 “say” 的向量表示。语料库中 “say”出现了两次，因此我们需要分别统计这两个 “say”的上下文，并在向量对应的位置进行计数，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-04.png?x-oss-process=image/resize,w_800" /></p><p>当对语料库中所有单词进行了词嵌入的处理后，我们可以得到所有的单词的向量表示，它们共同构成了一个矩阵，我们称之为<strong>共现矩阵</strong>（Co-ocurrence Matrix），如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-05.png?x-oss-process=image/resize,w_800" /></p><h2 id="优化">优化</h2><h3 id="高频词汇问题">高频词汇问题</h3><p>共现矩阵的元素表示每个单词与其上下文的单词共同出现的次数。这种表示方法对于高频词汇可能会出现问题。比如：the和 car 的共现次数会非常大，甚至要远远大于 car 和 drive的共现次数。对于这种情况，实际上我们不应该认为 the 和 car具有很强的相关性，因为 the 仅仅是一个常用词。</p><p>为了解决这个问题，我们可以使用 <strong>点互信息</strong>（PointwiseMutual Information，PMI）指标，其定义如下所示。</p><span class="math display">\[\begin{aligned}PMI(x, y) = log_2 \frac{P(x, y)}{P(x) P(y)}\end{aligned}\]</span><p>其中，<span class="math inline">\(P(x)\)</span> 表示 x发生的概率，<span class="math inline">\(P(y)\)</span> 表示 y发生的概率，<span class="math inline">\(P(x, y)\)</span> 表示 x 和 y同时发生的概率。PMI 的值越高，表明相关性越强。</p><p>假设语料库的单词数量为 10000，the 出现 100 次，car 出现 20 次，drive出现 10 次，the 和 car 共出现 10 次，car 和 drive 共出现 5次。那么，我们可以计算一下 PMI 值，如下所示。</p><span class="math display">\[\begin{aligned}PMI(the, car) = &amp; log_2 \frac{10 \cdot 10000}{1000 \cdot 20} \approx2.32\\PMI(car, drive) = &amp; log_2 \frac{5 \cdot 10000}{20 \cdot 10} \approx7.97\end{aligned}\]</span><p>通过PMI，我们可以解决高频词汇在共现矩阵中对于单词关联性的影响。不过，PMI也有一个问题，即当两个单词共现次数为0 时，<span class="math inline">\(log_2 0 =-\infty\)</span>。为了解决这问题，在实际中使用<strong>正的点互信息</strong>（PositivePMI，PPMI）来处理，其本质就是使用最小值 0来进行兜底，其定义如下所示。</p><span class="math display">\[\begin{aligned}PPMI(x, y) = max(0, PMI(x, y))\end{aligned}\]</span><p>经过 PPMI 的处理，我们可以将共现矩阵转换成 PPMI 矩阵，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-06.png?x-oss-process=image/resize,w_800" /></p><h3 id="向量维度问题">向量维度问题</h3><p>计数法还存在一个问题，那就是随着语料库词汇量的增加，各个单词向量的维数也会增加。如果语料库的词汇量达到10 万，那么单词向量的维度也会达到 10万。维度过高会导致处理计算量极大增加，而且向量中绝大多数元素都是0，会造成存储空间浪费。对此，我们要对向量进行<strong>降维</strong>（Dimensionality Reduction）。</p><p>降维的核心思想是<strong>从稀疏矩阵中找到重要的轴，用更少的维度重新表示</strong>。比如下图所示，我们发现二维向量的分布具有某种特点，而这种特点可以使用一维向量来表示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-07.png?x-oss-process=image/resize,w_800" /></p><p>具体而言，我们可以使用 <strong>奇异值分解</strong>（Singular ValueDecomposition，SVD）来进行降维。关于 SVD的工作原理，要解释明白的话，涉及的篇幅会很大，这里不作具体阐述，有兴趣的朋友可以自行去了解。</p><h1 id="推理法">推理法</h1><p>基于推理的方法，其本质上是基于神经网络来学习单词的内在含义，最终使用权重来表示单词的含义。推理法使用部分学习数据逐步进行学习；计数法则使用整个语料库进行数据统计，一次性处理得到单词的分布式表示。两者之间的差异如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-08.png?x-oss-process=image/resize,w_800" /></p><h2 id="语料库预处理-1">语料库预处理</h2><p>类似于计数法，推理法也会对语料库进行预处理，主要有以下几个步骤：</p><ul><li>对文本进行分词</li><li>为每个单词创建唯一的 ID</li><li>为每个单词定义 one-hot 表示</li></ul><p>什么是 one-hot 表示？one-hot表示会定义一个固定长度的向量，该向量的长度等于语料库中词汇的数量。每个单词的one-hot 表示中，只有其 ID 对应的元素为 1，其余元素均为 0。如下所示为one-hot 表示的示意图。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-09.png?x-oss-process=image/resize,w_800" /></p><h2 id="分布式表示-1">分布式表示</h2><p>推理法中单词的分布式表示是通过神经网络的权重来表示的。如下所示，输入层和中间层会构建一个全连接层，其中输入层的神经元数量等于one-hot 表示的长度，也就是语料库的词汇数量，比如7；中间层的神经元数量可以自定义，比如 3。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-10.png?x-oss-process=image/resize,w_800" /></p><p>通过学习，我们最终可以得到一个 7 x 3的权重矩阵。我们使用任意一个单词的 one-hot表示与权重矩阵进行乘积，即可提取到对应单词的分布式表示，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-11.png?x-oss-process=image/resize,w_800" /></p><h2 id="词嵌入-1">词嵌入</h2><p>推理法的词嵌入方法很多，本质而言，这些方法都是基于不同的神经网络模型实现的，通常我们会直接用神经网络模型表示不同的方法，比如：word2vec、gloVe等。这里我们主要介绍 word2vec 模型，其主要有两种架构，分别是：</p><ul><li>CBOW（Continuous Bag ofWords）模型：根据上下文的多个单词预测中间的单词</li><li>skip-gram 模型：根据中间的单词预测上下文的单词</li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-12.png?x-oss-process=image/resize,w_800" /></p><p>下面，我们分别来介绍这两种结构。</p><h3 id="cbow-模型">CBOW 模型</h3><p>CBOW模型，也称为连续词袋模型，其工作原理是通过上下文的多个单词来预测中间的单词。</p><p>CBOW 模型包含了输入层、中间层、输出层。输入层接收单词的 one-hot表示，中间层通过权重矩阵将输入转换成低维度的密集向量，输出层则使用softmax 函数预测目标词的概率分布，如下所示。输出层的各个神经元经过softmax 的计算可以得到各自的概率值，概率值最大的值转换成 1，其余转换成0，由此得到的 one-hot 表示可以对应一个单词。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-13.png?x-oss-process=image/resize,w_800" /></p><p>CBOW 模型基于 softmax 函数和交叉熵误差进行学习，两者可以构建一个Softmax with Loss 层，学习阶段时完备的神经网络结构如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-14.png?x-oss-process=image/resize,w_800" /></p><h3 id="skip-gram-模型">Skip-gram 模型</h3><p>Skip-gram 模型，其工作原理是通过中间的单词预测上下文的单词。</p><p>Skip-gram模型同样包含了输入层、中间层、输出。区别在于输出层只接收中间单词的one-hot编码，中间层同样通过权重矩阵转换成密集向量，输出层则为上下文中的每个单词分配概率，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-15.png?x-oss-process=image/resize,w_800" /></p><p>Skip-gram 模型的输出层数量与上下文单词的个数相同。因此需要通过Softmax with Loss 层分别求出各个输出层的损失并求和。具体而言，skip-gram模型的函数可以表示如下所示的函数。</p><span class="math display">\[\begin{aligned}L = - \frac{1}{T} \sum_{t=1}^{T} (logP(w_{t-1}|w_t) + logP(w_{t+1}|w_t))\end{aligned}\]</span><p>其中，<span class="math inline">\(P(w_{t-1}|w_t)\)</span> 表示给定<span class="math inline">\(w_t\)</span> 时，<spanclass="math inline">\(w_{t-1}\)</span> 发生的概率；<spanclass="math inline">\(P(w_{t+1}|w_t)\)</span> 表示给定 <spanclass="math inline">\(w_t\)</span> 时，<spanclass="math inline">\(w_{t+1}\)</span> 发生的概率。</p><h3 id="cbow-vs-skip-gram">CBOW vs skip-gram</h3><p>关于 CBOW 和 skip-gram的对比，从单词的分布式表示的准确度而言，skip-gram的表现更好。从学习速度而言，CBOW 的表现更快。</p><p>在实际应用中，更多会选择skip-gram。因为一旦生成了单词的分布式表示后，我们可以重复应用到各种场景中。因此可以无需考虑学习速度，更应该注重准确度。</p><h2 id="优化-1">优化</h2><p>推理法存在一个问题，即 one-hot 表示的维度问题。由于 one-hot表示的维度等于语料库的词汇量数量，如果语料库的词汇量为 100万，那么神经网络中各个层之间的矩阵运算的数据量将非常大，极其影响性能。</p><p>我们假设语料库词汇量有 100 万个，中间层神经元有 100 个，CBOW模型如下所示。下面，我们将以 word2vec 的 CBOW 模型为例，进行优化。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-16.png?x-oss-process=image/resize,w_800" /></p><h3 id="输入层维度问题">输入层维度问题</h3><p>首先，我们考虑 one-hot 表示对于输入层维度的影响。我们使用 one-hot表示作为输入，one-hot 表示和权重矩阵 <spanclass="math inline">\(W_{in}\)</span>的乘积显然会耗费巨大的计算资源，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-17.png?x-oss-process=image/resize,w_800" /></p><p>从图中可以看出，矩阵乘法的目的是为了取出单词的 ID 在权重矩阵 <spanclass="math inline">\(W_{in}\)</span>中对应行的向量，即单词对应的向量表示。对此，我们可以创建一个层，专门用于从权重参数中提取<strong>单词 ID 对应行（向量）</strong>，称之为 <strong>Embedding层</strong>。</p><p>Embedding 层以单词 ID作为输入，输出单词的向量表示。在反向传播时，权重只会更新单词 ID对应行的权重，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-18.png?x-oss-process=image/resize,w_800" /></p><p>当引入 Embedding 层之后，我们可以得到 CBOW模型的全貌图，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-20.png?x-oss-process=image/resize,w_800" /></p><h3 id="输出层维度问题">输出层维度问题</h3><p>其次，我们考虑 one-hot 表示对于输出层维度的影响。</p><p>首先，权重矩阵 <span class="math inline">\(W_{out}\)</span> 的大小是100 x 1000000，中间层的单词向量为 100。很显然，单词向量与权重矩阵 <spanclass="math inline">\(W_{out}\)</span>的乘积页会耗费巨大的计算资源。</p><p>其次，基于如下所示的 softmax 公式，我们可以看出 softmax也存在计算量过大的问题。</p><span class="math display">\[\begin{aligned}y_k = \frac{exp(s_k)}{\sum_{i=1}^{1000000} \space exp(s_i)}\end{aligned}\]</span><p>对此，我们使用 <strong>负采样</strong>（NegativeSampling）来进行优化。负采样的核心思想是<strong>使用二分类拟合多分类</strong>。举个例子，在未优化的实现中，我们使用softmax 计算出所有单词的概率值，由此构建一个 one-hot结果，来与正确值进行对比。而负采样的实现中，我们给定一个单词，神经网络计算这个单词的概率，判断它是否是正确值。这样就能将计算复杂度从O(N) 降低至 O(1)，其示意图如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-19.png?x-oss-process=image/resize,w_800" /></p><p>另一方面，<strong>在多分类的情况下，输出层使用 softmax函数将得分转化为概率，损失函数使用交叉熵误差。在二分类的情况下，输出层使用sigmoid 函数，损失函数也使用交叉熵误差</strong>。因此，我们可以进一步将Softmax-with-Loss 替换成Sigmoid-with-Loss，得到如下所示的二分类神经网络。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-21.png?x-oss-process=image/resize,w_800" /></p><p>至此，我们已经将多分类问题转换成了二分类问题，神经网络结构也进行了优化。下面，我们来看看二分类情况下如何进行学习。</p><h4 id="负采样的样本学习">负采样的样本学习</h4><p>负采样的样本学习，核心思想是使用一个正例、采样选择若干个负例，对这些数据的损失求和，基于此来进行学习，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/nlp-word-presentation-22.png?x-oss-process=image/resize,w_800" /></p><p>关于负例的选择，负采样的做法是：基于语料库中单词使用频率，计算出各个单词的概率分布，然后基于概率分布对单词进行采样。这样可以尽可能保证单词的出现概率与真实的情况类似。此外，为了避免稀有单词出现的概率过低，这里还会对概率分布进行调整，如下所示。</p><span class="math display">\[\begin{aligned}P&#39;(w_i) = \frac{P(w_i)^{0.75}}{\sum_j^n P(w_j)^{0.75}}\end{aligned}\]</span><h1 id="计算法-vs-推理法">计算法 vs 推理法</h1><p>最后，我们来简单对比一下计数法和推理法。</p><p>首先，两种方法在学习机制上存在差异。计数法通过对整个语料库的统计数据进行一次学习来获得单词的分布式表示；推理法则反复基于语料库的部分数据进行学习。每当语料库新增词汇时，计数法需要重新计算，完成共现矩阵生成、SVD等一系列操作。推理法则可以允许参数的增量学习，可以将之前学习到的权重作为下一次学习的初始值。</p><p>其次，两种方法在单词的分布式表示的性质上存在差异。计数法主要是编码单词的相似性。推理法除了单词的相似性之外，还能理解单词之间更复杂的模式，比如能够求解"king - man + woman = queen" 这样的类推问题。</p><p>当然，如果只评判单词的相似性，两种方法则不相上下。</p><h1 id="总结">总结</h1><p>本文我们主要介绍了自然语言处理中的核心问题之一——单词含义的表示。由此，我们介绍了三种方法，分别是词典法、计数法、推理法。</p><p>词典法的核心思想是<strong>将含义相同或含义类似的单词归类到同一个组中，同时定义单词之间细粒度的层级关系，比如：上位与下位的关系，整体与局部的关系</strong>。</p><p>计数法的核心思想是<strong>基于语料库（Corpus），根据上下文的窗口大小，统计目标词周围的单词的频率</strong>，这种方法的依据是<strong>分布式假设</strong>，即一个单词的含义是由其周围的单词形成。</p><p>推理法则是采用了神经网络的方式，学习样本数据，更新权重，以权重作为单词含义的表示。文中，我们介绍了word2vec 模型的两种架构，分别是 CBOW 和 skip-gram。</p><h1 id="参考">参考</h1><ol type="1"><li>《深度学习：自然语言处理》</li><li>《图解GPT》</li><li><ahref="https://www.53ai.com/news/qianyanjishu/2024070628415.html">一文彻底搞懂Transformer- Word Embedding（词嵌入）</a></li><li>Pennington, Jeffrey, Richard Socher, Christopher D. Manning.Glove:Global Vectors for Word Representation[J]. EMNLP. Vol.14. 2014.</li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;我们日常使用的语言，如中文、英文，称为
&lt;strong&gt;自然语言&lt;/strong&gt;（natural language）。
&lt;strong&gt;自然语言处理&lt;/strong&gt;（Natural Language
Processing，NLP）则是一种让计算机理解人类语言的技术。现在很多知名的工具，比如：搜索引擎、机器翻译、ChatGPT
等，都是基于自然语言处理技术的应用。&lt;/p&gt;</summary>
    
    
    
    <category term="自然语言处理" scheme="http://chuquan.me/categories/%E8%87%AA%E7%84%B6%E8%AF%AD%E8%A8%80%E5%A4%84%E7%90%86/"/>
    
    
    <category term="词典法" scheme="http://chuquan.me/tags/%E8%AF%8D%E5%85%B8%E6%B3%95/"/>
    
    <category term="计数法" scheme="http://chuquan.me/tags/%E8%AE%A1%E6%95%B0%E6%B3%95/"/>
    
    <category term="推理法" scheme="http://chuquan.me/tags/%E6%8E%A8%E7%90%86%E6%B3%95/"/>
    
    <category term="word2vec" scheme="http://chuquan.me/tags/word2vec/"/>
    
    <category term="CBOW" scheme="http://chuquan.me/tags/CBOW/"/>
    
    <category term="skip-gram" scheme="http://chuquan.me/tags/skip-gram/"/>
    
    <category term="SVD" scheme="http://chuquan.me/tags/SVD/"/>
    
    <category term="负采样" scheme="http://chuquan.me/tags/%E8%B4%9F%E9%87%87%E6%A0%B7/"/>
    
  </entry>
  
  <entry>
    <title>卷积神经网络</title>
    <link href="http://chuquan.me/2024/09/28/cnn-introduction/"/>
    <id>http://chuquan.me/2024/09/28/cnn-introduction/</id>
    <published>2024-09-28T12:27:29.000Z</published>
    <updated>2024-09-28T12:32:32.900Z</updated>
    
    <content type="html"><![CDATA[<p>通过前面几篇文章，我们对神经网络有了一个基本的认识。本文，我们来介绍一下神经网络在图像与视觉领域的应用——<strong>卷积神经网络</strong>（ConventionalNeural Network，简称 CNN）。</p><span id="more"></span><h1 id="视觉认知原理">视觉认知原理</h1><p>在深度学习的发展过程中，广泛应用了大脑认知原理，尤其是视觉认知原理，这里我们来简单了解一下视觉认知原理。</p><p>1981 年的诺贝尔医学奖，颁发给了 David Hubel 和 TorstenWiesel，以及RogerSperry。前两位的主要贡献，是发现了视觉系统的信息处理，可视皮层是分级的。整体而言，人类的视觉认知原理大体分为以下几个步骤：</p><ul><li>通过瞳孔摄入原始的像素信号</li><li>通过大脑皮层的细胞发现边缘和方向</li><li>对边缘和方向进行组合，从而完成形状判定</li><li>对形状进行高级视觉抽象，从而完成分类认知</li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/cnn-intro-01.png?x-oss-process=image/resize,w_800" /></p><p>人类视觉认知是一个分层递进的过程：提取边缘特征，抽象高级特征，组合整体图像，最终完成分类。下图所示是对于不同物体的视觉认知过程。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/cnn-intro-02.png?x-oss-process=image/resize,w_800" /></p><h1 id="全连接层的局限性">全连接层的局限性</h1><p>理论上，我们可以使用一个由全连接层组成的神经网络来进行图像识别，事实上，在<ahref="https://chuquan.me/2024/07/31/neural-network-implement/">初识人工神经网络（2）——代码实现</a>一文中，我们也是这么做的。</p><p>然而，对于复杂图像，全连接神经网络的表现并不好，根本原因在于<strong>全连接层忽略了数据的形状</strong>。图像是 3 维形状（宽、高、RGB通道），空间上邻近的像素会有相似的值，RGB各通道之间也有关联性。而全连接层会忽略形状，将数据作为一维数据进行处理，因此无法利用数据的空间关联信息。</p><p>因此，为了提取图像数据的空间关联信息，于是诞生了卷积神经网络。</p><h1 id="卷积神经网络">卷积神经网络</h1><p>我们首先来看一下全连接神经网络和卷积神经网络的整体结构，如下所示。两者的主要区别在于：<strong>在输入端，CNN络使用「卷积层 + ReLU + 池化层」的结构代替了「全连接层 +ReLU」的结构</strong>。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/cnn-intro-08.png?x-oss-process=image/resize,w_800" /></p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/cnn-intro-09.png?x-oss-process=image/resize,w_800" /></p><p>整体而言，卷积神经网络的工作流程主要分为三个阶段，分别是：</p><ul><li>通过 <strong>卷积层</strong>（ConvolutionLayer）对图像进行特征提取，得到特征图；</li><li>通过 <strong>池化层</strong>（PoolingLayer）对特征图进行降采样，压缩数据量；</li><li>通过 <strong>全连接层</strong>（AffineLayer）对特征图进行分类，得到识别结果。</li></ul><p>下面，我们分别介绍一下卷积层和池化层。</p><h1 id="卷积层">卷积层</h1><p>在 <ahref="https://chuquan.me/2024/03/30/foundation-of-computer-graphic-04/">《计算机图形学基础（4）——光栅化》</a>一文中，我们提到过卷积（Convolution）是实现滤波的主要数学工具和底层原理。滤波器的基本原理是<strong>响应函数与输入信号进行卷积运算</strong>，因此滤波器也可以称为<strong>卷积核</strong>。</p><p>卷积神经网络的核心在于卷积层，卷积层则包含了大量滤波器，这些滤波器通过监督学习，自适应调整内部权重参数，从而能够更加准确地提取特征值。下图所示是一个图片应用不同滤波器的示例。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/cnn-intro-05.png?x-oss-process=image/resize,w_800" /></p><h2 id="卷积运算">卷积运算</h2><p>了解了滤波器的作用后，我们再来看一下滤波器的内部原理——卷积运算。如下所示，是一个卷积运算的例子。其中，输入大小是<span class="math inline">\((4, 4)\)</span>，滤波器大小是 <spanclass="math inline">\((3, 3)\)</span>，输出大小是 <spanclass="math inline">\((2, 2)\)</span>。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/cnn-intro-06.png?x-oss-process=image/resize,w_800" /></p><p>下图所示展示了卷积运算的执行顺序。对于输入数据，卷积运算以一定步幅滑动滤波器的窗口，同时将滤波器元素和对应的输入元素相乘并求和，最终保存至对应的输出位置，即可得到卷积运算结果。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/cnn-intro-07.png?x-oss-process=image/resize,w_800" /></p><p>全连接神经网络中，除了权重参数，还存在偏置。同样，在 CNN中，也存在偏置，下图所示展示了包含偏置的卷积运算。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/cnn-intro-10.png?x-oss-process=image/resize,w_800" /></p><h2 id="填充">填充</h2><p>前面，我们介绍了一个卷积运算的例子，其输入大小是 <spanclass="math inline">\((4, 4)\)</span>，滤波器大小是 <spanclass="math inline">\((3, 3)\)</span>，输出大小是 <spanclass="math inline">\((2,2)\)</span>。此时可以会发现输出结果的大小已经小于滤波器的大小，将无法继续执行卷积运算，那么该如何解决呢？我们可以使用<strong>填充</strong>（Padding）来解决。</p><p>如下所示，我们对输入数据进行了幅度为 1 的填充，输入大小从 <spanclass="math inline">\((4, 4)\)</span> 变成了 <spanclass="math inline">\((6, 6)\)</span>，输出大小从 <spanclass="math inline">\((2, 2)\)</span> 变成了 <spanclass="math inline">\((6,6)\)</span>。由此可见，填充可以调整卷积运算的输出大小，填充增大，输出大小增大。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/cnn-intro-11.png?x-oss-process=image/resize,w_800" /></p><h2 id="步幅">步幅</h2><p>除了填充，我们还可以通过<strong>步幅</strong>（Stride）来控制输出大小。下图所示，我们设置步幅为2，滤波器每次滑动的间隔则为 2。整体而言，步幅增大，输出大小减小。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/cnn-intro-12.png?x-oss-process=image/resize,w_800" /></p><h2 id="滤波器通道">滤波器通道</h2><p>上述例子中的输入是 2 维数据，但是图像是 3 维数据（宽、高、RGB通道），对此，我们也可以为滤波器增加一个维度——通道。</p><p>下图所示，纵深方向为通道方向，输入数据的通道数为3，与此对应，滤波器的通道数也为3。每个通道的输入数据与对应通道的滤波器进行卷积运算，最后对所有通道的输出进行求和，从而得到输出结果。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/cnn-intro-13.png?x-oss-process=image/resize,w_800" /></p><p>此时，我们可以将输入数据抽象为 3 个维度，分别是通道数 <spanclass="math inline">\(C\)</span>、高度 <spanclass="math inline">\(H\)</span>，宽度 <spanclass="math inline">\(W\)</span>，整体使用 <spanclass="math inline">\((C, H, W)\)</span> 表示；滤波器也样，高度 <spanclass="math inline">\(FH\)</span>（Filter Height）、宽度 <spanclass="math inline">\(W\)</span>（Filter Width），通道数为 <spanclass="math inline">\(C\)</span>，整体使用 <spanclass="math inline">\((C, FH, FW)\)</span> 表示；输出大小为 <spanclass="math inline">\((1, OH, OW)\)</span>。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/cnn-intro-14.png?x-oss-process=image/resize,w_800" /></p><h2 id="滤波器数量">滤波器数量</h2><p>上述卷积运算一次只能输出一张特征图（FeatureMap），如果希望一次输出多张特征图，我们可以增加滤波器的数量，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/cnn-intro-15.png?x-oss-process=image/resize,w_800" /></p><h1 id="池化层">池化层</h1><p>如果卷积层的输入数据非常大，那么输出特征图的数据量也会非常大。为了降低后续处理的数据量，以及提高鲁棒性、防止过拟合，卷积神经网络会在卷积层之后增加一个池化层。</p><p>池化的本质是对输出特征图进行局部采样，也称降采样（Downsampling）。下图所示，是对一个输出特征图进行池化运算的示意图，其中使用的池化层为Max 池化层，即计算局部区域中的最大值。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/cnn-intro-16.png?x-oss-process=image/resize,w_800" /></p><p>对于多通道的场景，池化层将同时处理多个通道的数据，并进行池化运算，如下所示展示的是多通道池化运算顺序。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/cnn-intro-17.png?x-oss-process=image/resize,w_800" /></p><h1 id="全连接层">全连接层</h1><p>经过多层「卷积 + ReLU +池化」的运算，神经网络能够提炼出经过高度简化、高度抽象的特征，这些特征总结了图像的空间信息。最后，为了对图像进行分类识别，我们需要使用全连接层来进行处理。</p><p>如下图所示，全连接层会将池化层的输出结果进行降维输入，在监督学习的过程中，除了调整卷积层中的滤波器权重参数，也会调整全连接的权重参数，最终输出准确的分类结果。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/cnn-intro-18.png?x-oss-process=image/resize,w_800" /></p><h1 id="经典-cnn">经典 CNN</h1><p>时至今日，业界已经出现了各种卷积神经网络。这里，我们介绍其中两个经典的网络，一个是1998 年首次被提出的 CNN——LeNet；另一个是在深度学习受到关注的 2012年提出的 AlextNet。</p><h2 id="lenet">LeNet</h2><p>LeNet 在 1998年提出时是用于解决手写数字识别的问题。如下所示，它包含连续的卷积层和子采样层，最后通过全连接层输出结果。与现在的CNN 相比，LeNet 有几个不同点：</p><ul><li>LeNet 使用 sigmoid 函数作为激活函数，而现在的 CNN 主要使用 ReLU函数作为激活函数</li><li>Lext 采用子采样（Subsampling）进行数据压缩，而现在的 CNN主要使用池化层（Max 池化）进行数据压缩</li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/cnn-intro-19.png?x-oss-process=image/resize,w_800" /></p><h2 id="alexnet">AlexNet</h2><p>AlexNet 是图领奖获得者 Geoffrey Hinton 和他的学生 Alex Krizhevsky 在2012 年提出的，AlexNet 提出后引发了深度学习的浪潮，不过它的网络结构和LeNet 并没有本质的区别。</p><p>AlexNet 和 LeNet 之间的主要区别在于：</p><ul><li>AlexNet 采用了 ReLU 作为激活函数</li><li>AlexNet 采用了局部正规化的 LRN（Local ResponseNormalization）层</li><li>AlexNet 采用了 Drouput，可以有效抑制过拟合</li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/cnn-intro-20.png?x-oss-process=image/resize,w_800" /></p><h1 id="总结">总结</h1><p>本文我们主要介绍了卷积神经网络的基本概念及其工作原理，相比于全连接神经网络，卷积神经网络主要增加了卷积层和池化层，其中卷积层用于提取图像数据的空间特征，池化层用于压缩数据，提高鲁棒性，最后使用全连接层进行分类结果的计算和输出。</p><h1 id="参考">参考</h1><ol type="1"><li>《深度学习入门：基于Python的理论与实现》</li><li><ahref="https://cs231n.github.io/convolutional-networks/">ConvolutionalNeural Networks (CNNs / ConvNets)</a></li><li><ahref="https://www.zhihu.com/question/29366638/answer/3383464185">卷积神经网络和深度神经网络的区别是什么？</a></li><li><ahref="https://easyai.tech/ai-definition/cnn/#google_vignette">卷积神经网络-CNN</a></li><li><ahref="https://towardsdatascience.com/convolutional-neural-network-1368ee2998d3">ConvolutionalNeural Network</a></li><li><a href="https://mlnotebook.github.io/post/CNN1/">ConvolutionalNeural Networks - Basics</a></li><li><ahref="https://chuquan.me/2024/03/30/foundation-of-computer-graphic-04/#%E5%8D%B7%E7%A7%AF">卷积</a></li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;通过前面几篇文章，我们对神经网络有了一个基本的认识。本文，我们来介绍一下神经网络在图像与视觉领域的应用——&lt;strong&gt;卷积神经网络&lt;/strong&gt;（Conventional
Neural Network，简称 CNN）。&lt;/p&gt;</summary>
    
    
    
    <category term="机器学习" scheme="http://chuquan.me/categories/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0/"/>
    
    
    <category term="卷积层" scheme="http://chuquan.me/tags/%E5%8D%B7%E7%A7%AF%E5%B1%82/"/>
    
    <category term="池化层" scheme="http://chuquan.me/tags/%E6%B1%A0%E5%8C%96%E5%B1%82/"/>
    
    <category term="AlextNet" scheme="http://chuquan.me/tags/AlextNet/"/>
    
    <category term="LeNet" scheme="http://chuquan.me/tags/LeNet/"/>
    
  </entry>
  
  <entry>
    <title>神经网络的分层设计原理</title>
    <link href="http://chuquan.me/2024/09/21/deep-learning-layer/"/>
    <id>http://chuquan.me/2024/09/21/deep-learning-layer/</id>
    <published>2024-09-21T12:13:04.000Z</published>
    <updated>2024-09-21T12:18:21.985Z</updated>
    
    <content type="html"><![CDATA[<p>在深度学习中，为了便于组合任意结构、任意层次的神经网络，通常会对神经网络进行分层设计，这也是一种模块化的设计思想。</p><span id="more"></span><p>我们知道，神经网络的核心功能是 <strong>推理</strong> 和<strong>学习</strong>，也称为 <strong>正向传播</strong>（ForwardPropagation） 和 <strong>反向传播</strong>（BackwardPropagation）。为了实现结构化的分层设计，在局部层面，每个分层都必须同样具备对应的能力。</p><h1 id="计算图">计算图</h1><p>为了理解神经网络各个分层的工作原理，我们将使用<strong>计算图</strong>（ComputationalGraphic）的方式来分析推理和学习的过程。</p><p>下面，我们尝试计算图使用来分析一个简单的问题：</p><blockquote><p>张三在超市买了 2 个西瓜，其中西瓜每个 100 元，消费税为10%。对此，请计算两个问题： 1. 本次消费的总金额是多少？ 2.西瓜价格的上涨会在多大程度上影响最终的支付金额？即“支付金额关于西瓜价格的导数”是什么？</p></blockquote><h2 id="正向传播">正向传播</h2><p>对于第一个问题，我们可以得到如下所示的计算图的正向传播路径。其中，每个节点是一个数学函数，对其输入进行计算，得到对应的输出，并正向传播至下一个节点。</p><p>计算图可以将一个复杂的整体运算拆分成多个简单的局部运算。同时，将各个局部运算的结果不断地传递至其他计算节点，进而可以得到整体结果。最终可得：本次消费的总金额为220 元。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/deep-learning-layer-01.png?x-oss-process=image/resize,w_800" /></p><h2 id="反向传播">反向传播</h2><p>对于第二个问题，我们可以进一步得到计算图的反向传播路径。这里从右向左依次传递导数，1，1.1，2.2。最终可得：支付金额关于西瓜的价格的导数为2.2，即西瓜价格每上涨 1 元，最终支付价格会增加 2.2 元。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/deep-learning-layer-02.png?x-oss-process=image/resize,w_800" /></p><p>这里为什么使用反向传播计算导数呢？因为神经网络的学习过程就是基于损失函数的导数（更准确地说，是梯度）来进行的，所以列举了一个类似的问题。</p><h3 id="损失函数">损失函数</h3><p>神经网络的反向传播是围绕 <strong>损失函数</strong>（LossFunction，或称误差函数）完成的，其表示神经网络的性能，即当前神经网络的推理结果对比监督数据的正确值在多大程度上不拟合。</p><p>神经网络的学习过程的核心思想就是<strong>通过损失函数计算其梯度，并结合梯度下降法，根据梯度的正负值来更新权重</strong>。如果梯度值为负，通过调整权重参数向正方向改变，可以减小损失函数的值；如果梯度值为正，通过调整权重参数向负方向改变，可以减小损失函数的值。</p><p>下面，我们来介绍两个最常见的损失函数。</p><h4 id="均方误差">均方误差</h4><p><strong>均方误差</strong>（Mean SquaredError）的计算公式如下所示：</p><span class="math display">\[\begin{aligned}E = \frac{1}{2} \sum_k (y_k - t_k)^2\end{aligned}\]</span><p>其中，<span class="math inline">\(y_k\)</span>表示神经网络的输出，<span class="math inline">\(t_k\)</span>表示监督数据的正确值，<span class="math inline">\(k\)</span>表示数据的维度。</p><p>均方误差会计算神经网络的输出和监督数据的各个元素之差的平方，再求总和。我们在<ahref="https://chuquan.me/2024/07/21/neural-network-introduce/">《初识人工神经网络（1）——基本原理》</a>中介绍的损失函数就是均方误差。</p><p>根据定义，我们可以完成均方误差的代码实现，如下所示。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">mean_squared_error</span>(<span class="params">y, t</span>): </span><br><span class="line">    <span class="keyword">return</span> <span class="number">0.5</span> * np.<span class="built_in">sum</span>((y-t)**<span class="number">2</span>)</span><br></pre></td></tr></table></figure><h4 id="交叉熵误差">交叉熵误差</h4><p><strong>交叉熵误差</strong>（Cross EntropyError）的计算公式如下所示：</p><span class="math display">\[\begin{aligned}E = - \sum_k t_k log y_k\end{aligned}\]</span><p>其中，<span class="math inline">\(log\)</span> 表示以 <spanclass="math inline">\(e\)</span> 为底数的自然对数 <spanclass="math inline">\(log_e\)</span>，<spanclass="math inline">\(y_k\)</span> 表示神经网络的输出，<spanclass="math inline">\(t_k\)</span> 为监督数据的正确值。</p><p>这里采用 one-hot 表示法，即 <span class="math inline">\(t_k\)</span>中只有正确值的位置的值为 1，其他均为0。因此，交叉熵误差实际上只计算对应正确值标签的输出的自然对数。比如：当正确值标签的索引是2，如果对应的神经网络的输出是 0.6，那么交叉熵误差为 <spanclass="math inline">\(-log0.6 =0.51\)</span>；如果对应的神经网络的输出是 0.1，那么交叉熵误差为 <spanclass="math inline">\(-log0.1 =2.30\)</span>。由此可以看出，在正确值的位置的输出值越接近1，则误差越小。</p><p>根据定义，我们可以完成交叉熵误差的代码实现，如下所示。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">cross_entropy_error</span>(<span class="params">y, t</span>): </span><br><span class="line">    delta = <span class="number">1e-7</span></span><br><span class="line">    <span class="keyword">return</span> -np.<span class="built_in">sum</span>(t * np.log(y + delta))</span><br></pre></td></tr></table></figure><h3 id="链式法则">链式法则</h3><p>神经网络的学习过程是基于损失函数的导数（梯度）来完成的，由于反向传播的路径中包含了多个分层，以及大量的权重参数，因此我们需要考虑如何对损失函数的导数进行拆分，以便在计算图中的路径中进行传播。</p><p>很幸运，<strong>链式法则</strong>（ChainRule）可以完美地解决这个问题。什么是链式法则？链式法则是关于复合函数的导数的性质，其定义如下：</p><blockquote><p>如果某个函数由复合函数表示，则该复合函数的导数可以用构成复合函数的各个函数的导数的乘积表示。</p></blockquote><p>举个例子，有一个函数定义为 <span class="math inline">\(z = (x +y)^2\)</span>，此时我们可以将它拆解成两个函数，分别是：</p><span class="math display">\[\begin{aligned}z =&amp; t^2\\t =&amp; x + y\end{aligned}\]</span><p>对此，我们可以基于链式法则对复合函数 <spanclass="math inline">\(z\)</span> 关于 <spanclass="math inline">\(x\)</span> 到导数 <spanclass="math inline">\(\frac{\partial z}{\partial x}\)</span>进行求解，如下所示。</p><span class="math display">\[\begin{aligned}\frac{\partial z}{\partial x} = &amp;\frac{\partial z}{\partial t} \frac{\partial t}{\partial x}\\= &amp;2t \cdot 1\\= &amp;2(x + y)\end{aligned}\]</span><p>使用计算图进行拆解，可以得到如下所示的反向传播的计算路径。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/deep-learning-layer-03.png?x-oss-process=image/resize,w_800" /></p><h2 id="运算节点">运算节点</h2><p>在计算图中，节点是决定正向传播和反向传播的关键。下面，我们来看几种典型的运算节点。</p><h3 id="加法节点">加法节点</h3><p>加法节点用于处理加法运算，比如：<span class="math inline">\(z = x +y\)</span>。<span class="math inline">\(z\)</span> 关于 <spanclass="math inline">\(x\)</span> 和 <spanclass="math inline">\(y\)</span> 的导数都是常量 <spanclass="math inline">\(1\)</span>，其正向传播和反向传播的计算图如下所示。反向传播的导数仍然保持“上游传来的梯度”不变。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/deep-learning-layer-14.png?x-oss-process=image/resize,w_800" /></p><h3 id="乘法节点">乘法节点</h3><p>乘法节点用于处理乘法运算，比如：<span class="math inline">\(z = x\times y\)</span>。此时，我们可以分别求出 <spanclass="math inline">\(z\)</span> 关于 <spanclass="math inline">\(x\)</span> 和 <spanclass="math inline">\(y\)</span> 的导数，分别是：</p><span class="math display">\[\begin{aligned}\frac{\partial z}{\partial x} = y\\\frac{\partial z}{\partial y} = x\end{aligned}\]</span><p>乘法节点的正向传播和反向传播如下所示，其中反向传播会将“上游传来的梯度”乘以“将正向传播时的输入替换后的值”。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/deep-learning-layer-04.png?x-oss-process=image/resize,w_800" /></p><h3 id="分支节点">分支节点</h3><p>下图所示，分支节点是有分支的节点，本质上就是相同的值被复制并分叉，其反向传播是上游传来的梯度之和。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/deep-learning-layer-05.png?x-oss-process=image/resize,w_800" /></p><h3 id="repeat-节点">Repeat 节点</h3><p>分支节点有两个分支，Repeat 节点则有 N个分支。与分支节点类似，其反向传播也是通过 N 个梯度的总和求出。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/deep-learning-layer-06.png?x-oss-process=image/resize,w_800" /></p><h3 id="sum-节点">Sum 节点</h3><p>Sum 节点是通用的加法节点。Sum节点的反向传播将上游传来的梯度复制并分配至所有分支。我们可以发现，Sum节点和 Repeat 存在一种逆向关系，即 Sum 节点的正向传播相当于 Repeat节点的反向传播；Sum 节点的反向传播相当于 Repeat 节点的正向传播。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/deep-learning-layer-07.png?x-oss-process=image/resize,w_800" /></p><h3 id="matmul-节点">MatMul 节点</h3><p>MatMul 节点，即矩阵乘积（MatrixMultiply）节点。我们考虑一个矩阵乘法的例子 <span class="math inline">\(y= xW\)</span>。其中，<span class="math inline">\(x\)</span>、<spanclass="math inline">\(W\)</span>、<span class="math inline">\(y\)</span>的形状分别是 <span class="math inline">\(1 \times D\)</span>、<spanclass="math inline">\(D \times H\)</span>、<span class="math inline">\(1\times H\)</span>。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/deep-learning-layer-08.png?x-oss-process=image/resize,w_800" /></p><p>此时，我们可以通过如下方式求解关于 <spanclass="math inline">\(x\)</span> 的第 <spanclass="math inline">\(i\)</span> 个元素的导数 <spanclass="math inline">\(\frac{\partial L}{\partial x_i}\)</span>。<spanclass="math inline">\(\frac{\partial L}{\partial x_i}\)</span>表示变化程度，当 <span class="math inline">\(x_i\)</span>发生微小变化时，<span class="math inline">\(L\)</span>会有多大程度的变化。如果此时改变 <spanclass="math inline">\(x_i\)</span>，则向量 <spanclass="math inline">\(y\)</span> 的所有元素都会发生变化。由于 <spanclass="math inline">\(y\)</span> 的各个元素发生变化，最终 <spanclass="math inline">\(L\)</span> 也会发生变化。因此，<spanclass="math inline">\(x_i\)</span> 到 <spanclass="math inline">\(L\)</span> 的链式法则路径存在多个，它们的和是<span class="math inline">\(\frac{\partial L}{\partialx_i}\)</span>。</p><span class="math display">\[\begin{aligned}\frac{\partial L}{\partial x_i} = &amp;\sum_j \frac{\partial L}{\partial y_j} \frac{\partial y_j}{\partial x_i}\\= &amp;\sum_j \frac{\partial L}{\partial y_j} W_{ij}\end{aligned}\]</span><p>由上式可知，<span class="math inline">\(\frac{\partial L}{\partialx_i}\)</span> 由向量 <span class="math inline">\(\frac{\partialL}{\partial y}\)</span> 和 <span class="math inline">\(W\)</span> 的第<span class="math inline">\(i\)</span>行向量的内积求得，进而推导得到：</p><span class="math display">\[\begin{aligned}\frac{\partial L}{\partial x} = \frac{\partial L}{\partial y} W^T\end{aligned}\]</span><p><span class="math inline">\(\frac{\partial L}{\partial x}\)</span>可由矩阵乘积一次求得，其中 <span class="math inline">\(W^T\)</span>表示矩阵 <span class="math inline">\(W\)</span> 的转置矩阵。</p><p>当我们考虑 mini-batch 处理的情况，即 <spanclass="math inline">\(x\)</span> 中保存了 <spanclass="math inline">\(N\)</span> 份数据。此时，<spanclass="math inline">\(x\)</span>、<spanclass="math inline">\(W\)</span>、<span class="math inline">\(y\)</span>的形状分别是 <span class="math inline">\(N \times D\)</span>、<spanclass="math inline">\(D \times H\)</span>、<span class="math inline">\(N\times H\)</span>，其计算图如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/deep-learning-layer-09.png?x-oss-process=image/resize,w_800" /></p><p>从 <span class="math inline">\(\frac{\partial L}{\partial x}\)</span>的关系式，我们发现矩阵乘积的反向传播与乘法的反向传播类似，同样可以总结出“上游传来的梯度”乘以“将正向传播时的输入替换后的值”。最后，我们进一步通过确认矩阵的形状，可以推导出矩阵乘法的反向传播的数学式，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/deep-learning-layer-10.png?x-oss-process=image/resize,w_800" /></p><h1 id="分层设计">分层设计</h1><p>在 <ahref="https://chuquan.me/2024/07/21/neural-network-introduce/">《初识人工神经网络（1）——基本原理》</a>中我们介绍了一个数字识别的神经网络，其结构如下所示，隐藏层的节点具备两个处理函数，分别是求和函数、激活函数。对此，为了实现分层设计，我们将进一步拆分成仿射层、激活函数层。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-14.png?x-oss-process=image/resize,w_800" /></p><h2 id="affine">Affine</h2><p>求和函数本质上对各个输入进行加权求和，通过矩阵点乘实现。此时，我们再引入一个偏置，用于控制神经元被激活的容易程度。由于神经网络的加权求和运算与加偏置运算，正好对应仿射变换的一次线性变换和一次平移，因此将其称为仿射层，或Affine 层。如下所示是 Affine 计算图的正向传播路径。其中，<spanclass="math inline">\(X\)</span> 表示输入矩阵，<spanclass="math inline">\(W\)</span> 表示权重矩阵，<spanclass="math inline">\(B\)</span> 表示偏置矩阵。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/deep-learning-layer-11.png?x-oss-process=image/resize,w_800" /></p><h3 id="计算图-1">计算图</h3><p>通过上述计算图，我们可以发现 Affine 层是由一个 MatMul节点和一个加法节点组成。由此，我们可以得到其计算图的反向传播路径，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/deep-learning-layer-12.png?x-oss-process=image/resize,w_800" /></p><h3 id="代码实现">代码实现</h3><p>根据计算图，我们可以很容易得到 Affine 层的代码实现，如下所示。<figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">Affine</span>:</span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self, W, b</span>):</span><br><span class="line">        self.W = W </span><br><span class="line">        self.b = b </span><br><span class="line">        self.x = <span class="literal">None</span> </span><br><span class="line">        self.dW = <span class="literal">None</span> </span><br><span class="line">        self.db = <span class="literal">None</span></span><br><span class="line">    </span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">forward</span>(<span class="params">self, x</span>): </span><br><span class="line">        self.x = x</span><br><span class="line">        out = np.dot(x, self.W) + self.b</span><br><span class="line">        <span class="keyword">return</span> out</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">backward</span>(<span class="params">self, dout</span>):</span><br><span class="line">        dx = np.dot(dout, self.W.T) </span><br><span class="line">        self.dW = np.dot(self.x.T, dout) </span><br><span class="line">        self.db = np.<span class="built_in">sum</span>(dout, axis=<span class="number">0</span>)</span><br><span class="line">        <span class="keyword">return</span> dx</span><br></pre></td></tr></table></figure></p><h2 id="sigmoid">Sigmoid</h2><p>Sigmoid 是激活函数的一种，其数学定义如下所示。</p><span class="math display">\[\begin{aligned}y = &amp;\frac{1}{1 + exp(-x)}\\= &amp;\frac{1}{1 + e^{-x}}\end{aligned}\]</span><p>根据其数学定义，我们对其运算步骤进行节点拆解，可以得到如下所示计算图的正向传播路径。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/deep-learning-layer-13.png?x-oss-process=image/resize,w_800" /></p><h3 id="计算图-2">计算图</h3><p>在 sigmoid 计算图中，我们注意到有两个没有介绍过的节点，分别是：<spanclass="math inline">\(exp\)</span> 节和除法节点。</p><p>对于 <span class="math inline">\(exp\)</span> 节点，其数学表示为<span class="math inline">\(y = exp(x)\)</span>，其导数由下式表示。</p><span class="math display">\[\begin{aligned}\frac{\partial y}{\partial x}=&amp; log_e e \cdot e^x\\=&amp; e^x\\=&amp; exp(x)\end{aligned}\]</span><p>对于除法节点，其数学表示为 <span class="math inline">\(y =\frac{1}{x}\)</span>，其导数由下式表示。</p><span class="math display">\[\begin{aligned}\frac{\partial y}{\partial x}=&amp; -\frac{1}{x^2}\\=&amp; -y^2\end{aligned}\]</span><p>然后，我们根据链式法则可以推导出如下所示的反向传播路径。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/deep-learning-layer-15.png?x-oss-process=image/resize,w_800" /></p><p>接下来，我们对 sigmoid 反向输出的导数进行简化，推导如下。</p><span class="math display">\[\begin{aligned}\frac{\partial L}{\partial y} y^2 exp(-x)=&amp; \frac{\partial L}{\partial y} \frac{1}{(1 + exp(-x))^2} exp(-x)\\=&amp; \frac{\partial L}{\partial y} \frac{1}{1 + exp(-x)}\frac{exp(-x)}{1 + exp(-x)} \\=&amp; \frac{\partial L}{\partial y} y (1 - y)\end{aligned}\]</span><p>然后，我们再隐藏计算图过程中节点，合并成一个 sigmoid节点，可以得到如下所示的计算图。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/deep-learning-layer-16.png?x-oss-process=image/resize,w_800" /></p><h3 id="代码实现-1">代码实现</h3><p>根据计算图，我们可以很容易得到 Sigmoid 层的代码实现，如下所示。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">Sigmoid</span>:</span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self</span>):</span><br><span class="line">        self.out = <span class="literal">None</span></span><br><span class="line">    </span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">forward</span>(<span class="params">self, x</span>):</span><br><span class="line">        out = <span class="number">1</span> / (<span class="number">1</span> + np.exp(-x)) </span><br><span class="line">        self.out = out</span><br><span class="line">        <span class="keyword">return</span> out</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">backward</span>(<span class="params">self, dout</span>):</span><br><span class="line">        dx = dout * (<span class="number">1.0</span> - self.out) * self.out</span><br><span class="line">        <span class="keyword">return</span> dx</span><br></pre></td></tr></table></figure><h2 id="relu">ReLU</h2><p>ReLU 是另一种常用的激活函数，其数学表达式非常简单，如下所示。</p><span class="math display">\[\begin{aligned}y = \begin{cases}x &amp; (x &gt; 0)\\0 &amp; (x \leq 0)\end{cases}\end{aligned}\]</span><h3 id="计算图-3">计算图</h3><p>根据 ReLU 的数学定义，我们可以求解 <spanclass="math inline">\(y\)</span> 关于 <spanclass="math inline">\(x\)</span> 的导数，如下所示。</p><span class="math display">\[\begin{aligned}\frac{\partial y}{\partial x} = \begin{cases}1 &amp; (x &gt; 0)\\0 &amp; (x \leq 0)\end{cases}\end{aligned}\]</span><p>下图所示为 ReLU 的计算图。当正向传播时的输入 <spanclass="math inline">\(x\)</span> 大于0，则反向传播会将上游的值直接传递至下游；当正向传播时的输入 <spanclass="math inline">\(x\)</span> 小于等于 0，则导数传递停止。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/deep-learning-layer-17.png?x-oss-process=image/resize,w_800" /></p><h3 id="代码实现-2">代码实现</h3><p>根据计算图，我们可以得到 ReLU 层的代码实现，如下所示。其中，变量<code>mask</code> 是由 True/False 构成的 NumPy 数组，它会把正向传播时的输入 <code>x</code> 的元素中小于等于 0的地方保存为 True，其他地方(大于 0 的元素)保存为 False。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">Relu</span>:</span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self</span>):</span><br><span class="line">        self.mask = <span class="literal">None</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">forward</span>(<span class="params">self, x</span>): </span><br><span class="line">        self.mask = (x &lt;= <span class="number">0</span>) </span><br><span class="line">        out = x.copy() </span><br><span class="line">        out[self.mask] = <span class="number">0</span></span><br><span class="line">        <span class="keyword">return</span> out</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">backward</span>(<span class="params">self, dout</span>): </span><br><span class="line">        dout[self.mask] = <span class="number">0</span> </span><br><span class="line">        dx = dout</span><br><span class="line">        <span class="keyword">return</span> dx</span><br></pre></td></tr></table></figure><h2 id="softmax-with-loss">Softmax-with-Loss</h2><p>对于分类问题，尤其是多元分类问题，一般使用 Softmax函数作为计算输出。Softmax 函数的数学定义如下所示。</p><span class="math display">\[\begin{aligned}y_k = &amp;\frac{exp(a_k)}{\sum\limits_{i=1}\limits^n exp(a_i)}\end{aligned}\]</span><p>其中，<span class="math inline">\(exp(x)\)</span> 表示 <spanclass="math inline">\(e^x\)</span> 的指数函数。如果输出层有 <spanclass="math inline">\(n\)</span> 个神经元，<spanclass="math inline">\(y_k\)</span> 表示第 <spanclass="math inline">\(k\)</span> 个神经元的输出值。Softmax函数中的分子是输入信号 <span class="math inline">\(a_k\)</span>的指数函数，分母是所有输入信号的指数函数之和。Softmax 函数的输出是 0.0到 1.0 之间的实数，且 Softmax 函数的输出值总和为 1。因此，我们将 Softmax函数的输出解释为“概率”。</p><p>神经网络包含 <strong>推理</strong> 和 <strong>学习</strong>两个阶段。在推理阶段通常不使用 Softmax 层，因为 Softmax只是对前一层的数据进行了归一化处理。推理阶段只需要用最后的 Affine层输出的最大值即可。不过，在学习阶段则需要 Softmax层。为了配合监督数据的正确值进行学习，通常会使用 Softmax结合交叉熵误差（Cross Entropy Error）损失函数，构成一个<strong>Softmax-with-Loss</strong> 层。</p><p>上文，我们介绍过交叉熵误差，这里我们再简单回顾一下它的数学定义，如下所示。</p><span class="math display">\[\begin{aligned}L = - \sum\limits_k t_k log y_k\end{aligned}\]</span><h3 id="计算图-4">计算图</h3><p>下图所示是 Softmax-with-Loss层的计算图的整体示意图。计算图内部可以分为 Softmax 和 Cross EntropyError两个层，其各自则是由多个基本运算节点构成。注意，在计算图中，我们将指数之和简写为<span class="math inline">\(S\)</span>，最终的输出计为 <spanclass="math inline">\((y_1, y_2, y_3)\)</span>。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/deep-learning-layer-18.png?x-oss-process=image/resize,w_800" /></p><p>这里，我们重点看一下反向传播。</p><p>首先是 Cross Entropy Error 层的反向传播，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/deep-learning-layer-19.png?x-oss-process=image/resize,w_800" /></p><p>其主要注意以下几个要点：</p><ul><li>反向传播初始值为 1。因为 <span class="math inline">\(\frac{\partialL}{\partial L} = 1\)</span>。</li><li>乘法节点的反向传播是将正向传播时的输入值进行翻转，乘以上游传来的导数后，传递至下游。</li><li>加法节点的反向传播是将上游传来的导数继续进行传递。</li><li>对数节点中对数的导数是 <span class="math inline">\(\frac{\partialy}{\partial x} =\frac{1}{x}\)</span>，其反向传播则根据链式法则，使用上游传来的导数乘以自身的导数，并将结果传递至下游。</li></ul><p>其次，我们来看 Softmax 层的反向传播，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/deep-learning-layer-20.png?x-oss-process=image/resize,w_800" /></p><p>对于乘法节点，其反向传播将正向传播时的输入值进行翻转，乘以上游传来的导数。其包含两个反向传播分支，具体计算如下所示。</p><span class="math display">\[\begin{aligned}- \frac{t_1}{y_1} exp(a_1) =&amp; - t_1 \frac{S}{exp(a_1)} exp(a_1) = -t_1 S\\- \frac{t_1}{y_1} \frac{1}{S} =&amp; -\frac{t_1}{exp(a_1)}\end{aligned}\]</span><p>接下来是一个 Repeat 节点和除法节点的组合。对于 Repeat节点，其反向传播时会将上游节点的导数进行求和，得到 <spanclass="math inline">\(-S(t_1 + t_2 +t_3)\)</span>；对于除法节点，其反向传播可以进一步得到 <spanclass="math inline">\(\frac{1}{S}(t_1 + t_2 + t_3)\)</span>。由于 <spanclass="math inline">\((t_1, t_2, t_3)\)</span> 采用 one-hot表示法，其和为 1，因此进一步得到反向传播的值为 <spanclass="math inline">\(\frac{1}{S}\)</span>。</p><p>然后是加法节点，其反向传播将上游传来的导数继续进行传递。</p><p>最后是 exp 节点，从计算图看，它其实包含了一个 exp 节点和一个 Repeat节点。对于 Repeat 节点，其反向传播会将上游节点的导数进行求和；对于 exp节点，其导数为 <span class="math inline">\(\frac{\partial y}{\partial x}= exp(x)\)</span>。反向传播推导如下：</p><span class="math display">\[\begin{aligned}\frac{\partial y}{\partial y} = (\frac{1}{S} - \frac{t_1}{exp(a_1)})exp(a_1) = y_1 - t_1\end{aligned}\]</span><h3 id="代码实现-3">代码实现</h3><p>最后，我们根据计算图进行代码实现，结果如下所示。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">cross_entropy_error</span>(<span class="params">y, t</span>): </span><br><span class="line">    delta = <span class="number">1e-7</span></span><br><span class="line">    <span class="keyword">return</span> -np.<span class="built_in">sum</span>(t * np.log(y + delta))</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">softmax</span>(<span class="params">a</span>):</span><br><span class="line">    c = np.<span class="built_in">max</span>(a)</span><br><span class="line">    exp_a = np.exp(a - c) <span class="comment"># 溢出对策 sum_exp_a = np.sum(exp_a)</span></span><br><span class="line">    y = exp_a / sum_exp_a</span><br><span class="line">    <span class="keyword">return</span> y</span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">SoftmaxWithLoss</span>: </span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self</span>):</span><br><span class="line">        self.loss = <span class="literal">None</span> <span class="comment"># 损失</span></span><br><span class="line">        self.y = <span class="literal">None</span> <span class="comment"># softmax的输出</span></span><br><span class="line">        self.t = <span class="literal">None</span> <span class="comment"># 监督数据(one-hot vector)</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">forward</span>(<span class="params">self, x, t</span>): self.t = t</span><br><span class="line">        self.y = softmax(x)</span><br><span class="line">        self.loss = cross_entropy_error(self.y, self.t)</span><br><span class="line">        <span class="keyword">return</span> self.loss</span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">backward</span>(<span class="params">self, dout=<span class="number">1</span></span>):</span><br><span class="line">        batch_size = self.t.shape[<span class="number">0</span>]</span><br><span class="line">        dx = (self.y - self.t) / batch_size</span><br><span class="line">        <span class="keyword">return</span> dx</span><br></pre></td></tr></table></figure><h1 id="总结">总结</h1><p>在实践中，分层设计有利于快速调整和实现各种各样的神经网络。我们经常听说的深度学习，本质上就是一个神经网络层级比较多的结构。</p><p>为了介绍神经网络中的分层设计原理，本文以计算图作为出发点，作为分析每个分层正向传播和反向传播的依据。这里，我们还介绍了一个用于反向传播的关键法则——链式法则。最后，我们介绍了神经网络中几种常见的分层，如：Affine、Sigmoid、ReLU、Softmax-with-Loss等。</p><p>本文，我们大致了解了神经网络的分层设计，这种思想是研究复杂神经网络的基础。后续有时间，我们将继续探索神经网络的各种应用，敬请期待吧！</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;在深度学习中，为了便于组合任意结构、任意层次的神经网络，通常会对神经网络进行分层设计，这也是一种模块化的设计思想。&lt;/p&gt;</summary>
    
    
    
    <category term="机器学习" scheme="http://chuquan.me/categories/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0/"/>
    
    
    <category term="计算图" scheme="http://chuquan.me/tags/%E8%AE%A1%E7%AE%97%E5%9B%BE/"/>
    
    <category term="Sigmoid" scheme="http://chuquan.me/tags/Sigmoid/"/>
    
    <category term="Affine" scheme="http://chuquan.me/tags/Affine/"/>
    
    <category term="Softmax" scheme="http://chuquan.me/tags/Softmax/"/>
    
    <category term="Softmax-with-Loss" scheme="http://chuquan.me/tags/Softmax-with-Loss/"/>
    
    <category term="ReLU" scheme="http://chuquan.me/tags/ReLU/"/>
    
  </entry>
  
  <entry>
    <title>初识人工神经网络（2）——代码实现</title>
    <link href="http://chuquan.me/2024/07/31/neural-network-implement/"/>
    <id>http://chuquan.me/2024/07/31/neural-network-implement/</id>
    <published>2024-07-31T15:20:15.000Z</published>
    <updated>2024-12-03T01:43:01.901Z</updated>
    
    <content type="html"><![CDATA[<p>前一篇 <a href="https://chuquan.me/2024/07/21/neural-network-introduce">文章</a> 我们介绍了人工神经网络的基本原理，本文我们将使用 Python 来实现一个简易的神经网络，可用于识别手写数字，从而加深对于神经网络的理解。</p><span id="more"></span><p>本文实现的完整代码 <a href="https://github.com/baochuquan/implementing-neural-network">传送门</a>。</p><h1 id="神经网络实现"><a class="markdownIt-Anchor" href="#神经网络实现"></a> 神经网络实现</h1><p>根据我们的理解，神经网络应该至少包含三个部分：</p><ul><li><strong>初始化</strong>：初始化输入层节点、隐藏层节点、输出层节点的数量。</li><li><strong>训练</strong>：通过特定的训练样本，优化连接权重。</li><li><strong>查询</strong>：给定输入，计算输出。</li></ul><p>对此，我们定义一个 <code>NeuralNetwork</code> 类来表示神经网络，其内部包含三个函数分别对应初始化、训练、查询，如下所示。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># neural network class definition </span></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">NeuralNetwork</span> :</span><br><span class="line">    <span class="comment"># initialise the neural network </span></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__init__</span>() :</span><br><span class="line">        <span class="keyword">pass</span></span><br><span class="line"></span><br><span class="line">    <span class="comment"># train the neural network </span></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">train</span>() :</span><br><span class="line">        <span class="keyword">pass</span></span><br><span class="line">    </span><br><span class="line">    <span class="comment"># query the neural network </span></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">query</span>() :</span><br><span class="line">        <span class="keyword">pass</span></span><br></pre></td></tr></table></figure><p>接下来，我们依次来实现神经网络的各个部分。</p><h2 id="初始化网络"><a class="markdownIt-Anchor" href="#初始化网络"></a> 初始化网络</h2><h3 id="网络结构"><a class="markdownIt-Anchor" href="#网络结构"></a> 网络结构</h3><p>首先，我们定义神经网络的基本结构，其包含三个层：输入层、隐藏层、输出层。此外，我们还需要定义学习率，代码如下所示。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># initialise the neural network</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params"> self , inputnodes, hiddennodes, outputnodes, learningrate </span>) :</span><br><span class="line">    <span class="comment"># set number of nodes in each input, hidden, output layer</span></span><br><span class="line">    self.inodes = inputnodes</span><br><span class="line">    self.hnodes = hiddennodes</span><br><span class="line">    self.onodes = outputnodes</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># learning rate</span></span><br><span class="line">    self.lr = learningrate</span><br><span class="line">    <span class="keyword">pass</span></span><br></pre></td></tr></table></figure><h3 id="连接权重"><a class="markdownIt-Anchor" href="#连接权重"></a> 连接权重</h3><p>其次，定义神经网络的核心参数——连接权重。这里涉及两部分连接权重，分别是：</p><ul><li>输入层与隐藏层之间的连接权重矩阵：<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><msub><mi>W</mi><mrow><mi>i</mi><mi>n</mi><mi>p</mi><mi>u</mi><mi>t</mi><mi mathvariant="normal">_</mi><mi>h</mi><mi>i</mi><mi>d</mi><mi>d</mi><mi>e</mi><mi>n</mi></mrow></msub></mrow><annotation encoding="application/x-tex">W_{input\_hidden}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1.05033em;vertical-align:-0.367em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.13889em;">W</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.33610799999999996em;"><span style="top:-2.5500000000000003em;margin-left:-0.13889em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">i</span><span class="mord mathnormal mtight">n</span><span class="mord mathnormal mtight">p</span><span class="mord mathnormal mtight">u</span><span class="mord mathnormal mtight">t</span><span class="mord mtight" style="margin-right:0.02778em;">_</span><span class="mord mathnormal mtight">h</span><span class="mord mathnormal mtight">i</span><span class="mord mathnormal mtight">d</span><span class="mord mathnormal mtight">d</span><span class="mord mathnormal mtight">e</span><span class="mord mathnormal mtight">n</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.367em;"><span></span></span></span></span></span></span></span></span></span>，其大小为 <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>h</mi><mi>i</mi><mi>d</mi><mi>d</mi><mi>e</mi><mi>n</mi><mi mathvariant="normal">_</mi><mi>n</mi><mi>o</mi><mi>d</mi><mi>e</mi><mi>s</mi><mo>×</mo><mi>i</mi><mi>n</mi><mi>p</mi><mi>u</mi><mi>t</mi><mi mathvariant="normal">_</mi><mi>n</mi><mi>o</mi><mi>d</mi><mi>e</mi><mi>s</mi></mrow><annotation encoding="application/x-tex">hidden\_nodes \times input\_nodes</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1.00444em;vertical-align:-0.31em;"></span><span class="mord mathnormal">h</span><span class="mord mathnormal">i</span><span class="mord mathnormal">d</span><span class="mord mathnormal">d</span><span class="mord mathnormal">e</span><span class="mord mathnormal">n</span><span class="mord" style="margin-right:0.02778em;">_</span><span class="mord mathnormal">n</span><span class="mord mathnormal">o</span><span class="mord mathnormal">d</span><span class="mord mathnormal">e</span><span class="mord mathnormal">s</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">×</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:1.00444em;vertical-align:-0.31em;"></span><span class="mord mathnormal">i</span><span class="mord mathnormal">n</span><span class="mord mathnormal">p</span><span class="mord mathnormal">u</span><span class="mord mathnormal">t</span><span class="mord" style="margin-right:0.02778em;">_</span><span class="mord mathnormal">n</span><span class="mord mathnormal">o</span><span class="mord mathnormal">d</span><span class="mord mathnormal">e</span><span class="mord mathnormal">s</span></span></span></span>。</li><li>隐藏层与输出层之间的连接权重矩阵：<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><msub><mi>W</mi><mrow><mi>h</mi><mi>i</mi><mi>d</mi><mi>d</mi><mi>e</mi><mi>n</mi><mi mathvariant="normal">_</mi><mi>o</mi><mi>u</mi><mi>t</mi><mi>p</mi><mi>u</mi><mi>t</mi></mrow></msub></mrow><annotation encoding="application/x-tex">W_{hidden\_output}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1.05033em;vertical-align:-0.367em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.13889em;">W</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.33610799999999996em;"><span style="top:-2.5500000000000003em;margin-left:-0.13889em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">h</span><span class="mord mathnormal mtight">i</span><span class="mord mathnormal mtight">d</span><span class="mord mathnormal mtight">d</span><span class="mord mathnormal mtight">e</span><span class="mord mathnormal mtight">n</span><span class="mord mtight" style="margin-right:0.02778em;">_</span><span class="mord mathnormal mtight">o</span><span class="mord mathnormal mtight">u</span><span class="mord mathnormal mtight">t</span><span class="mord mathnormal mtight">p</span><span class="mord mathnormal mtight">u</span><span class="mord mathnormal mtight">t</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.367em;"><span></span></span></span></span></span></span></span></span></span>，其大小为 <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>h</mi><mi>i</mi><mi>d</mi><mi>d</mi><mi>e</mi><mi>n</mi><mi mathvariant="normal">_</mi><mi>n</mi><mi>o</mi><mi>d</mi><mi>e</mi><mi>s</mi><mo>×</mo><mi>o</mi><mi>u</mi><mi>t</mi><mi>p</mi><mi>u</mi><mi>t</mi><mi mathvariant="normal">_</mi><mi>n</mi><mi>o</mi><mi>d</mi><mi>e</mi><mi>s</mi></mrow><annotation encoding="application/x-tex">hidden\_nodes \times output\_nodes</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1.00444em;vertical-align:-0.31em;"></span><span class="mord mathnormal">h</span><span class="mord mathnormal">i</span><span class="mord mathnormal">d</span><span class="mord mathnormal">d</span><span class="mord mathnormal">e</span><span class="mord mathnormal">n</span><span class="mord" style="margin-right:0.02778em;">_</span><span class="mord mathnormal">n</span><span class="mord mathnormal">o</span><span class="mord mathnormal">d</span><span class="mord mathnormal">e</span><span class="mord mathnormal">s</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">×</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:1.00444em;vertical-align:-0.31em;"></span><span class="mord mathnormal">o</span><span class="mord mathnormal">u</span><span class="mord mathnormal">t</span><span class="mord mathnormal">p</span><span class="mord mathnormal">u</span><span class="mord mathnormal">t</span><span class="mord" style="margin-right:0.02778em;">_</span><span class="mord mathnormal">n</span><span class="mord mathnormal">o</span><span class="mord mathnormal">d</span><span class="mord mathnormal">e</span><span class="mord mathnormal">s</span></span></span></span>。</li></ul><p><img src="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-15.png?x-oss-process=image/resize,w_800" alt="" /></p><p>在 Python 中，我们使用经典的数学库 <code>numpy</code> 来实现矩阵的表示和运算，通过如下的方式我们可以定义一个 <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>r</mi><mi>o</mi><mi>w</mi><mi>s</mi><mo>×</mo><mi>c</mi><mi>o</mi><mi>l</mi><mi>u</mi><mi>m</mi><mi>n</mi><mi>s</mi></mrow><annotation encoding="application/x-tex">rows \times columns</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord mathnormal" style="margin-right:0.02778em;">r</span><span class="mord mathnormal">o</span><span class="mord mathnormal" style="margin-right:0.02691em;">w</span><span class="mord mathnormal">s</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">×</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.69444em;vertical-align:0em;"></span><span class="mord mathnormal">c</span><span class="mord mathnormal">o</span><span class="mord mathnormal" style="margin-right:0.01968em;">l</span><span class="mord mathnormal">u</span><span class="mord mathnormal">m</span><span class="mord mathnormal">n</span><span class="mord mathnormal">s</span></span></span></span> 的数组，数组元素是 0 ~ 1 的随机值。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">numpy.random.rand(rows, columns)</span><br></pre></td></tr></table></figure><p>在前一篇文章中，我们介绍过权重的范围在 <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mo stretchy="false">[</mo><mo>−</mo><mn>1.1</mn><mo stretchy="false">]</mo></mrow><annotation encoding="application/x-tex">[-1. 1]</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mopen">[</span><span class="mord">−</span><span class="mord">1</span><span class="mord">.</span><span class="mord">1</span><span class="mclose">]</span></span></span></span> 之间。这里我们可以通过上述方式生成随机数之后再减去 0.5，从而使数组元素变成 <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mo stretchy="false">[</mo><mo>−</mo><mn>0.5</mn><mo separator="true">,</mo><mn>0.5</mn><mo stretchy="false">]</mo></mrow><annotation encoding="application/x-tex">[-0.5, 0.5]</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mopen">[</span><span class="mord">−</span><span class="mord">0</span><span class="mord">.</span><span class="mord">5</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.16666666666666666em;"></span><span class="mord">0</span><span class="mord">.</span><span class="mord">5</span><span class="mclose">]</span></span></span></span> 之间的随机值，如下所示。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">self.wih = (numpy.random.rand(self.hnodes, self.inodes) - <span class="number">0.5</span>)</span><br><span class="line">self.who = (numpy.random.rand(self.onodes, self.hnodes) - <span class="number">0.5</span>)</span><br></pre></td></tr></table></figure><p>我们之前还提到过一种优化的权重初始化方案：<strong>在一个节点传入连接数量平方根倒数的范围内进行正态分布采样</strong>，即权重范围是 <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mo stretchy="false">[</mo><mn>1</mn><mi mathvariant="normal">/</mi><msqrt><mtext>传入连接数</mtext></msqrt><mo separator="true">,</mo><mo>−</mo><mn>1</mn><mi mathvariant="normal">/</mi><msqrt><mtext>传入连接数</mtext></msqrt><mo stretchy="false">]</mo></mrow><annotation encoding="application/x-tex">[1/\sqrt{传入连接数}, -1/\sqrt{传入连接数}]</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1.176665em;vertical-align:-0.25em;"></span><span class="mopen">[</span><span class="mord">1</span><span class="mord">/</span><span class="mord sqrt"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.9266650000000001em;"><span class="svg-align" style="top:-3em;"><span class="pstrut" style="height:3em;"></span><span class="mord" style="padding-left:0.833em;"><span class="mord cjk_fallback">传</span><span class="mord cjk_fallback">入</span><span class="mord cjk_fallback">连</span><span class="mord cjk_fallback">接</span><span class="mord cjk_fallback">数</span></span></span><span style="top:-2.886665em;"><span class="pstrut" style="height:3em;"></span><span class="hide-tail" style="min-width:0.853em;height:1.08em;"><svg width='400em' height='1.08em' viewBox='0 0 400000 1080' preserveAspectRatio='xMinYMin slice'><path d='M95,702c-2.7,0,-7.17,-2.7,-13.5,-8c-5.8,-5.3,-9.5,-10,-9.5,-14c0,-2,0.3,-3.3,1,-4c1.3,-2.7,23.83,-20.7,67.5,-54c44.2,-33.3,65.8,-50.3,66.5,-51c1.3,-1.3,3,-2,5,-2c4.7,0,8.7,3.3,12,10s173,378,173,378c0.7,0,35.3,-71,104,-213c68.7,-142,137.5,-285,206.5,-429c69,-144,104.5,-217.7,106.5,-221l0 -0c5.3,-9.3,12,-14,20,-14H400000v40H845.2724s-225.272,467,-225.272,467s-235,486,-235,486c-2.7,4.7,-9,7,-19,7c-6,0,-10,-1,-12,-3s-194,-422,-194,-422s-65,47,-65,47zM834 80h400000v40h-400000z'/></svg></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.11333499999999996em;"><span></span></span></span></span></span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.16666666666666666em;"></span><span class="mord">−</span><span class="mord">1</span><span class="mord">/</span><span class="mord sqrt"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.9266650000000001em;"><span class="svg-align" style="top:-3em;"><span class="pstrut" style="height:3em;"></span><span class="mord" style="padding-left:0.833em;"><span class="mord cjk_fallback">传</span><span class="mord cjk_fallback">入</span><span class="mord cjk_fallback">连</span><span class="mord cjk_fallback">接</span><span class="mord cjk_fallback">数</span></span></span><span style="top:-2.886665em;"><span class="pstrut" style="height:3em;"></span><span class="hide-tail" style="min-width:0.853em;height:1.08em;"><svg width='400em' height='1.08em' viewBox='0 0 400000 1080' preserveAspectRatio='xMinYMin slice'><path d='M95,702c-2.7,0,-7.17,-2.7,-13.5,-8c-5.8,-5.3,-9.5,-10,-9.5,-14c0,-2,0.3,-3.3,1,-4c1.3,-2.7,23.83,-20.7,67.5,-54c44.2,-33.3,65.8,-50.3,66.5,-51c1.3,-1.3,3,-2,5,-2c4.7,0,8.7,3.3,12,10s173,378,173,378c0.7,0,35.3,-71,104,-213c68.7,-142,137.5,-285,206.5,-429c69,-144,104.5,-217.7,106.5,-221l0 -0c5.3,-9.3,12,-14,20,-14H400000v40H845.2724s-225.272,467,-225.272,467s-235,486,-235,486c-2.7,4.7,-9,7,-19,7c-6,0,-10,-1,-12,-3s-194,-422,-194,-422s-65,47,-65,47zM834 80h400000v40h-400000z'/></svg></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.11333499999999996em;"><span></span></span></span></span></span><span class="mclose">]</span></span></span></span>，相关代码实现如下所示。其中 <code>numpy.random.normal()</code> 函数用于实现正态分布采样，传入的三个参数分别是：正态分布值的中心、标准方差、数组大小。<code>pow(self.hnodes, -0.5)</code> 相当于节点数量的 -0.5 次方。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">self.wih = numpy.random.normal( <span class="number">0.0</span> , <span class="built_in">pow</span>(self.hnodes, -<span class="number">0.5</span>) , (self.hnodes, self.inodes) )</span><br><span class="line">self.who = numpy.random.normal( <span class="number">0.0</span> , <span class="built_in">pow</span>(self.onodes, -<span class="number">0.5</span>) , (self.onodes, self.hnodes) )</span><br></pre></td></tr></table></figure><p>最后我们结合网络结构和连接权重，可以得到完整的初始化代码，如下所示。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># initialise the neural network</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params"> self , inputnodes, hiddennodes, outputnodes, learningrate </span>) :</span><br><span class="line">    <span class="comment"># set number of nodes in each input, hidden, output layer</span></span><br><span class="line">    self.inodes = inputnodes</span><br><span class="line">    self.hnodes = hiddennodes</span><br><span class="line">    self.onodes = outputnodes</span><br><span class="line">    </span><br><span class="line">    self.wih = numpy.random.normal ( <span class="number">0.0</span> , <span class="built_in">pow</span>(self.hnodes, -<span class="number">0.5</span>) , (self.hnodes, self.inodes) )</span><br><span class="line">    self.who = numpy.random.normal ( <span class="number">0.0</span> , <span class="built_in">pow</span>(self.onodes, -<span class="number">0.5</span>) , (self.onodes, self.hnodes) )</span><br><span class="line"></span><br><span class="line">    <span class="comment"># learning rate</span></span><br><span class="line">    self.lr = learningrate</span><br><span class="line">    <span class="keyword">pass</span></span><br></pre></td></tr></table></figure><h2 id="查询网络"><a class="markdownIt-Anchor" href="#查询网络"></a> 查询网络</h2><p>查询网络本质上就是信号转换的过程。我们知道每一个神经元都会对输入信号进行两次处理，分别是求和函数和激活函数，对应的矩阵表达式如下所示。</p><p>\begin{aligned}<br />X_{hidden} = &amp; W_{input_hidden} \cdot I<br />\<br />\<br />O_{hidden} = &amp; sigmoid(X_{hidden})<br />\end{aligned}</p><p>对于求和函数，我们可以通过 numpy 库中的矩阵点乘函数来实现，如下所示。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">hidden_inputs = numpy.dot(self.wih, inputs)</span><br></pre></td></tr></table></figure><p>对于激活函数，我们使用 <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>s</mi><mi>i</mi><mi>g</mi><mi>m</mi><mi>o</mi><mi>i</mi><mi>d</mi></mrow><annotation encoding="application/x-tex">sigmoid</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8888799999999999em;vertical-align:-0.19444em;"></span><span class="mord mathnormal">s</span><span class="mord mathnormal">i</span><span class="mord mathnormal" style="margin-right:0.03588em;">g</span><span class="mord mathnormal">m</span><span class="mord mathnormal">o</span><span class="mord mathnormal">i</span><span class="mord mathnormal">d</span></span></span></span> 激活函数，它的表达式是 <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mfrac><mn>1</mn><mrow><mn>1</mn><mo>+</mo><msup><mi>e</mi><mrow><mo>−</mo><mi>x</mi></mrow></msup></mrow></mfrac></mrow><annotation encoding="application/x-tex">\frac{1}{1+e^{-x}}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1.2484389999999999em;vertical-align:-0.403331em;"></span><span class="mord"><span class="mopen nulldelimiter"></span><span class="mfrac"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.845108em;"><span style="top:-2.655em;"><span class="pstrut" style="height:3em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight">1</span><span class="mbin mtight">+</span><span class="mord mtight"><span class="mord mathnormal mtight">e</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.7026642857142857em;"><span style="top:-2.786em;margin-right:0.07142857142857144em;"><span class="pstrut" style="height:2.5em;"></span><span class="sizing reset-size3 size1 mtight"><span class="mord mtight"><span class="mord mtight">−</span><span class="mord mathnormal mtight">x</span></span></span></span></span></span></span></span></span></span></span></span><span style="top:-3.23em;"><span class="pstrut" style="height:3em;"></span><span class="frac-line" style="border-bottom-width:0.04em;"></span></span><span style="top:-3.394em;"><span class="pstrut" style="height:3em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight">1</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.403331em;"><span></span></span></span></span></span><span class="mclose nulldelimiter"></span></span></span></span></span>。对此，SciPy Python 库中的 <code>expit()</code> 函数实现了 <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>s</mi><mi>i</mi><mi>g</mi><mi>m</mi><mi>o</mi><mi>i</mi><mi>d</mi></mrow><annotation encoding="application/x-tex">sigmoid</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8888799999999999em;vertical-align:-0.19444em;"></span><span class="mord mathnormal">s</span><span class="mord mathnormal">i</span><span class="mord mathnormal" style="margin-right:0.03588em;">g</span><span class="mord mathnormal">m</span><span class="mord mathnormal">o</span><span class="mord mathnormal">i</span><span class="mord mathnormal">d</span></span></span></span> 函数。我们可以在初始化方法中加入一下这段代码。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">self.activation_function = <span class="keyword">lambda</span> x: scipy.special.expit(x)</span><br></pre></td></tr></table></figure><p>然后，我们将上述求和函数结果输入至激活函数中，即可得到隐藏层的输出，如下所示。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">hidden_outputs = self.activation_function(hidden_inputs)</span><br></pre></td></tr></table></figure><p>输出层的信号转换本质上与隐藏层一样，因此我们可以添加两行类似的代码来对输出层进行处理。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">final_inputs = numpy.dot(self.who, hidden_outputs)</span><br><span class="line">final_outputs = self.activation_function(final_inputs)</span><br></pre></td></tr></table></figure><p>最后我们得到完整的查询网络代码如下所示。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># query the neural network</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">query</span>(<span class="params">self, inputs_list</span>) :</span><br><span class="line">    <span class="comment"># convert inputs list to 2d array</span></span><br><span class="line">    inputs = numpy.array(inputs_list, ndmin=<span class="number">2</span>).T</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># calculate signals into hidden layer</span></span><br><span class="line">    hidden_inputs = numpy.dot(self.wih, inputs)</span><br><span class="line">    <span class="comment"># calculate the signals emerging from hidden layer</span></span><br><span class="line">    hidden_outputs = self.activation_function(hidden_inputs)</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># calculate signals into final output layer</span></span><br><span class="line">    final_inputs = numpy.dot(self.who, hidden_outputs)</span><br><span class="line">    <span class="comment"># calculate the signals emerging from final output layer</span></span><br><span class="line">    final_outputs = self.activation_function(final_inputs)</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">return</span> final_outputs</span><br></pre></td></tr></table></figure><h2 id="训练网络"><a class="markdownIt-Anchor" href="#训练网络"></a> 训练网络</h2><p>训练网络主要包含两部分：</p><ul><li>正向的信号转换</li><li>反向的权重更新</li></ul><h3 id="信号转换"><a class="markdownIt-Anchor" href="#信号转换"></a> 信号转换</h3><p>信号转换的过程与上述查询网络一致，因此我们可以直接照搬相关代码，如下所示。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">train</span>(<span class="params">self, inputs_list, targets_list</span>):</span><br><span class="line">    <span class="comment"># convert inputs list to 2d array</span></span><br><span class="line">    inputs = numpy.array(inputs_list, ndmin=<span class="number">2</span>).T</span><br><span class="line">    targets = numpy.array(targets_list, ndmin=<span class="number">2</span>).T</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># calculate signals into hidden layer</span></span><br><span class="line">    hidden_inputs = numpy.dot(self.wih, inputs)</span><br><span class="line">    <span class="comment"># calculate the signals emerging from hidden layer</span></span><br><span class="line">    hidden_outputs = self.activation_function(hidden_inputs)</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># calculate signals into final output layer</span></span><br><span class="line">    final_inputs = numpy.dot(self.who, hidden_outputs)</span><br><span class="line">    <span class="comment"># calculate the signals emerging from final output layer</span></span><br><span class="line">    final_outputs = self.activation_function(final_inputs)</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">pass</span></span><br></pre></td></tr></table></figure><h3 id="权重更新"><a class="markdownIt-Anchor" href="#权重更新"></a> 权重更新</h3><p>对于一个三层神经网络，我们只需要更新两部分权重，分别是：</p><ul><li>隐藏层与输出层的权重更新</li><li>输入层与隐藏层的权重更新</li></ul><p>权重更新是基于误差实现的，因此我们首先要计算误差，对于输出层的误差，计算样本的预期目标输出值与实际计算输出值的差即可；对于隐藏层的误差，我们在上一篇文章中进行了公式推导，如下所示。</p><p>\begin{aligned}<br />error_{hidden} = W^T_{hidden_output} \cdot error_{output}<br />\end{aligned}</p><p>由此，我们可以得到如下所示的误差计算代码。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># error is the (target - actual)</span></span><br><span class="line">output_errors = targets - final_outputs</span><br><span class="line"></span><br><span class="line"><span class="comment"># hidden layer error is the output_errors, split by weights, recombined at hidden nodes</span></span><br><span class="line">hidden_errors = numpy.dot(self.who.T, output_errors)</span><br></pre></td></tr></table></figure><p>然后，我们根据上一篇文章中推导的权重更新公式来实现相关代码，其公式如下所示。其中 <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>α</mi></mrow><annotation encoding="application/x-tex">\alpha</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.43056em;vertical-align:0em;"></span><span class="mord mathnormal" style="margin-right:0.0037em;">α</span></span></span></span> 是学习率。基于此，我们可以分别实现隐藏层与输出层的权重更新、输入层与隐藏层的权重更新。</p><p><img src="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-42.png?x-oss-process=image/resize,w_800" alt="" /></p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># update the weights for the links between the hidden and output layers</span></span><br><span class="line">self.who += self.lr * numpy.dot(( output_errors * final_outputs * (<span class="number">1.0</span> - final_outputs)), numpy.transpose(hidden_outputs))</span><br><span class="line"></span><br><span class="line"><span class="comment"># update the weights for the links between the input and hidden layers</span></span><br><span class="line">self.wih += self.lr * numpy.dot(( hidden_errors * hidden_outputs * (<span class="number">1.0</span> - hidden_outputs)), numpy.transpose(inputs))</span><br></pre></td></tr></table></figure><p>最后，我们可以得到完整的训练网络的相关代码，如下所示。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">train</span>(<span class="params">self, inputs_list, targets_list</span>):</span><br><span class="line">    <span class="comment"># convert inputs list to 2d array</span></span><br><span class="line">    inputs = numpy.array(inputs_list, ndmin=<span class="number">2</span>).T</span><br><span class="line">    targets = numpy.array(targets_list, ndmin=<span class="number">2</span>).T</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># calculate signals into hidden layer</span></span><br><span class="line">    hidden_inputs = numpy.dot(self.wih, inputs)</span><br><span class="line">    <span class="comment"># calculate the signals emerging from hidden layer</span></span><br><span class="line">    hidden_outputs = self.activation_function(hidden_inputs)</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># calculate signals into final output layer</span></span><br><span class="line">    final_inputs = numpy.dot(self.who, hidden_outputs)</span><br><span class="line">    <span class="comment"># calculate the signals emerging from final output layer</span></span><br><span class="line">    final_outputs = self.activation_function(final_inputs)</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># output layer error is the (target - actual) </span></span><br><span class="line">    output_errors = targets - final_outputs</span><br><span class="line">    <span class="comment"># hidden layer error is the output_errors, split by weights, recombined at hidden nodes</span></span><br><span class="line">    hidden_errors = numpy.dot(self.who.T, output_errors)</span><br><span class="line"></span><br><span class="line">    <span class="comment"># update the weights for the links between the hidden and output layers</span></span><br><span class="line">    self.who += self.lr * numpy.dot((output_errors * final_outputs * (<span class="number">1.0</span> - final_outputs)), numpy.transpose(hidden_outputs))</span><br><span class="line"></span><br><span class="line">    <span class="comment"># update the weights for the links between the input and hidden layers</span></span><br><span class="line">    self.wih += self.lr * numpy.dot((hidden_errors * hidden_outputs * (<span class="number">1.0</span> - hidden_outputs)), numpy.transpose(inputs))</span><br><span class="line"></span><br><span class="line">    <span class="keyword">pass</span></span><br></pre></td></tr></table></figure><h1 id="神经网络测试"><a class="markdownIt-Anchor" href="#神经网络测试"></a> 神经网络测试</h1><p>神经网络适合用于解决不具有固定模式或计算步骤的问题，比如：图像识别、语音识别等。这里我们尝试让神经网络解决一个类似的问题——手写数字识别。下图所示是一个手写数字的示例，我们可能会对于这个数字是 4 还是 9 产生分歧。此时，神经网络就可以作为判断的辅助工具，我们的目的也是如此。</p><p><img src="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-imp-01.png?x-oss-process=image/resize,w_800" alt="" /></p><h2 id="训练数据"><a class="markdownIt-Anchor" href="#训练数据"></a> 训练数据</h2><p>经典的手写数字数据库 <a href="https://yann.lecun.com/exdb/mnist/">MNIST</a> 为我们提供了训练和测试的样本数据，由于 MNIST 数据库的格式不易使用，我们使用别人构建的相对简单的 CSV 文件：</p><ul><li>训练集 <a href="http://www.pjreddie.com/media/files/mnist_train.csv">http://www.pjreddie.com/media/files/mnist_train.csv</a></li><li>测试集 <a href="http://www.pjreddie.com/media/files/mnist_test.csv">http://www.pjreddie.com/media/files/mnist_test.csv</a></li></ul><p><code>mnist_train.csv</code> 和 <code>mnist_test.csv</code> 中的每一行代表一个样本数据，每个样本数据由 785 个数字组成，由逗号进行分隔。其中第 1 个数字是样本的预期目标值；其余 784 个数字素是样本的输入数据，本质上是一个打平的 28 x 28 的二维矩阵，每个数字表示一个颜色值，范围在 <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mo stretchy="false">[</mo><mn>0</mn><mo separator="true">,</mo><mn>255</mn><mo stretchy="false">]</mo></mrow><annotation encoding="application/x-tex">[0, 255]</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mopen">[</span><span class="mord">0</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.16666666666666666em;"></span><span class="mord">2</span><span class="mord">5</span><span class="mord">5</span><span class="mclose">]</span></span></span></span> 之间。如下所示，是一个样本数据的示例。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,18,18,18,126,136,175,26,166,255,247,127,0,0,0,0,0,0,0,0,0,0,0,0,30,36,94,154,170,253,253,253,253,253,225,172,253,242,195,64,0,0,0,0,0,0,0,0,0,0,0,49,238,253,253,253,253,253,253,253,253,251,93,82,82,56,39,0,0,0,0,0,0,0,0,0,0,0,0,18,219,253,253,253,253,253,198,182,247,241,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,80,156,107,253,253,205,11,0,43,154,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,14,1,154,253,90,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,139,253,190,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,11,190,253,70,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,35,241,225,160,108,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,81,240,253,253,119,25,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,45,186,253,253,150,27,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,16,93,252,253,187,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,249,253,249,64,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,46,130,183,253,253,207,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,39,148,229,253,253,253,250,182,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,24,114,221,253,253,253,253,201,78,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,23,66,213,253,253,253,253,198,81,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,18,171,219,253,253,253,253,195,80,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,55,172,226,253,253,253,253,244,133,11,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,136,253,253,253,212,135,132,16,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0</span><br></pre></td></tr></table></figure><p>将此样本数据转换成图像，可以得到如下所示的手写数字图像，通过肉眼判断手写数字与预期目标值 5 是相符合的。</p><p><img src="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-imp-02.png?x-oss-process=image/resize,w_800" alt="" /></p><h2 id="构建网络"><a class="markdownIt-Anchor" href="#构建网络"></a> 构建网络</h2><p>对于输入层节点，由于样本的输入数据是 28 x 28 = 784，因此在初始化网络时，传入的 <code>input_nodes</code> 也应该是 784。</p><p>对于输出层节点，由于神经网络是对图像进行分类，在此情况下，分类的结果应该是 [0, 9] 共 10 个数字中的任意一个，因此在初始化网络时，传入的 <code>output_nodes</code> 也应该是 10。</p><p>对于隐藏层节点，这也是一个可自由调节的参数。由于隐藏层是为了提取输入数据的特征和模式，理论上可以比输入更简洁，因此我们可以考虑使用一个比输入节点数更小的数来表示，比如：100。同样，学习率也是一个可自由调节的参数，这里我们暂停为 0.3。</p><p>如下所示，是网络构建的相关代码实现。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># number of input, hidden and output nodes</span></span><br><span class="line">input_nodes = <span class="number">784</span></span><br><span class="line">hidden_nodes = <span class="number">100</span></span><br><span class="line">output_nodes = <span class="number">10</span></span><br><span class="line">    </span><br><span class="line"><span class="comment"># learning rate is 0.3</span></span><br><span class="line">learning_rate = <span class="number">0.3</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># create instance of neural network</span></span><br><span class="line">n = neuralNetwork(input_nodes,hidden_nodes,output_nodes, learning_rate)</span><br></pre></td></tr></table></figure><h2 id="样本数据预处理"><a class="markdownIt-Anchor" href="#样本数据预处理"></a> 样本数据预处理</h2><p>在上一篇文章中我们提到输入值的范围应该是 <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mo stretchy="false">(</mo><mn>0</mn><mo separator="true">,</mo><mn>1</mn><mo stretchy="false">]</mo></mrow><annotation encoding="application/x-tex">(0, 1]</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mopen">(</span><span class="mord">0</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.16666666666666666em;"></span><span class="mord">1</span><span class="mclose">]</span></span></span></span>，而当前的样本输入值的范围是 <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mo stretchy="false">[</mo><mn>0</mn><mo separator="true">,</mo><mn>255</mn><mo stretchy="false">]</mo></mrow><annotation encoding="application/x-tex">[0, 255]</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mopen">[</span><span class="mord">0</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.16666666666666666em;"></span><span class="mord">2</span><span class="mord">5</span><span class="mord">5</span><span class="mclose">]</span></span></span></span>。因此我们需要进行样本数据处理，可以考虑将其缩小至 <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mo stretchy="false">[</mo><mn>0.01</mn><mo separator="true">,</mo><mn>1.0</mn><mo stretchy="false">]</mo></mrow><annotation encoding="application/x-tex">[0.01, 1.0]</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mopen">[</span><span class="mord">0</span><span class="mord">.</span><span class="mord">0</span><span class="mord">1</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.16666666666666666em;"></span><span class="mord">1</span><span class="mord">.</span><span class="mord">0</span><span class="mclose">]</span></span></span></span> 的范围之中，相关的处理代码如下所示。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">inputs = (numpy.asfarray(all_values[<span class="number">1</span>:]) / <span class="number">255.0</span> * <span class="number">0.99</span>) + <span class="number">0.01</span></span><br></pre></td></tr></table></figure><p>除了样本输出值，我们还需要处理样本目标值。当前输出层具有 10 个节点，输出值最大的节点的索引就是手写数字的识别结果，如下所示。对此，理想情况是定义一个数组，只有一个元素为 1，其余元素为 0。然而激活函数无法输出 0 和 1，此时如果将目标值设为 0 或 1，将会导致神经网络反馈过大的权重。因此我们要对这些目标值进行微调，使用 0.01 和 0.99 来代替 0 和 1，实现代码如下所示。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">#output nodes is 10 (example)</span></span><br><span class="line">onodes = <span class="number">10</span></span><br><span class="line">targets = numpy.zeros(onodes) + <span class="number">0.01</span></span><br><span class="line">targets[<span class="built_in">int</span>(all_values[<span class="number">0</span>])] = <span class="number">0.99</span></span><br></pre></td></tr></table></figure><p><img src="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-imp-03.png?x-oss-process=image/resize,w_800" alt="" /></p><p>如下所示是输入数据预处理的相关代码实现。我们首先打开样本数据的 <code>csv</code> 文件，然后循环依次遍历每一个样本数据，拆分成目标值和输入值，最后对它们进行预处理，从而匹配神经网络的值的约束。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># load the mnist training data CSV file into a list </span></span><br><span class="line">training_data_file = <span class="built_in">open</span>(<span class="string">&quot;./mnist_train.csv&quot;</span>, <span class="string">&#x27;r&#x27;</span>) </span><br><span class="line">training_data_list = training_data_file.readlines() </span><br><span class="line">training_data_file.close()</span><br><span class="line"></span><br><span class="line"><span class="comment"># go through all records in the training data set</span></span><br><span class="line"><span class="keyword">for</span> record <span class="keyword">in</span> training_data_list:</span><br><span class="line">    <span class="comment"># split the record by the &#x27;,&#x27; commas</span></span><br><span class="line">    all_values = record.split(<span class="string">&#x27;,&#x27;</span>)</span><br><span class="line">    <span class="comment"># scale and shift the inputs</span></span><br><span class="line">    inputs = (numpy.asfarray(all_values[<span class="number">1</span>:]) / <span class="number">255.0</span> * <span class="number">0.99</span>) + <span class="number">0.01</span> </span><br><span class="line">    <span class="comment"># create the target output values (all 0.01, except the desired label which is 0.99)</span></span><br><span class="line">    targets = numpy.zeros(output_nodes) + <span class="number">0.01</span></span><br><span class="line">    <span class="comment"># all_values[0] is the target label for this record</span></span><br><span class="line">    targets[<span class="built_in">int</span>(all_values[<span class="number">0</span>])] = <span class="number">0.99</span></span><br><span class="line">    </span><br><span class="line">    <span class="comment"># train the neural network</span></span><br><span class="line">    n.train(inputs, targets)</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">pass</span></span><br></pre></td></tr></table></figure><h2 id="训练与测试"><a class="markdownIt-Anchor" href="#训练与测试"></a> 训练与测试</h2><p>我们使用 <code>mnist_train.csv</code> 完成了训练之后，可以使用 <code>mnist_test.csv</code> 来进行测试。我们只要再读取测试数据，输入至已训练的神经网络中查询结果，并比对输出值与目标值。为了有一个衡量标准，我们对每次样本测试进行打分，查询结果正确 +1，最后计算正确率，相关代码实现如下所示。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># load the mnist test data CSV file into a list</span></span><br><span class="line">test_data_file = <span class="built_in">open</span>(<span class="string">&quot;./mnist_test.csv&quot;</span>, <span class="string">&#x27;r&#x27;</span>)</span><br><span class="line">test_data_list = test_data_file.readlines()</span><br><span class="line">test_data_file.close()</span><br><span class="line"></span><br><span class="line"><span class="comment"># test the neural network</span></span><br><span class="line"><span class="comment"># scorecard for how well the network performs, initially empty </span></span><br><span class="line">scorecard = []</span><br><span class="line"></span><br><span class="line"><span class="comment"># go through all the records in the test data set</span></span><br><span class="line"><span class="keyword">for</span> record <span class="keyword">in</span> test_data_list:</span><br><span class="line">    <span class="comment"># split the record by the &#x27;,&#x27; commas</span></span><br><span class="line">    all_values = record.split(<span class="string">&#x27;,&#x27;</span>)</span><br><span class="line">    <span class="comment"># correct answer is first value</span></span><br><span class="line">    correct_label = <span class="built_in">int</span>(all_values[<span class="number">0</span>])</span><br><span class="line">    <span class="comment"># scale and shift the inputs</span></span><br><span class="line">    inputs = (numpy.asfarray(all_values[<span class="number">1</span>:]) / <span class="number">255.0</span> * <span class="number">0.99</span>) + <span class="number">0.01</span></span><br><span class="line">     <span class="comment"># query the network</span></span><br><span class="line">    outputs = n.query(inputs)</span><br><span class="line">    <span class="comment"># the index of the highest value corresponds to the label </span></span><br><span class="line">    label = numpy.argmax(outputs)</span><br><span class="line">    <span class="comment"># append correct or incorrect to list</span></span><br><span class="line">    <span class="keyword">if</span> (label == correct_label):</span><br><span class="line">        <span class="comment"># network&#x27;s answer matches correct answer, add 1 to scorecard</span></span><br><span class="line">        scorecard.append(<span class="number">1</span>)</span><br><span class="line">    <span class="keyword">else</span>:<span class="comment"># network&#x27;s answer doesn&#x27;t match correct answer, add 0 to scorecard</span></span><br><span class="line">        scorecard.append(<span class="number">0</span>)</span><br><span class="line">        <span class="keyword">pass</span></span><br><span class="line">    <span class="keyword">pass</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># calculate the performance score, the fraction of correct answers</span></span><br><span class="line">scorecard_array = numpy.asarray(scorecard)</span><br><span class="line"><span class="built_in">print</span> (<span class="string">&quot;performance = &quot;</span>, scorecard_array.<span class="built_in">sum</span>() / scorecard_array.size)</span><br></pre></td></tr></table></figure><h1 id="神经网络优化"><a class="markdownIt-Anchor" href="#神经网络优化"></a> 神经网络优化</h1><p>至此，我们实现了一个手写数字识别的神经网络，并完成了评测。虽然评测得分还不错，正确率超过 95%，但是它还可以进一步优化。下面我们简单介绍几种优化思路。</p><h2 id="调整学习率"><a class="markdownIt-Anchor" href="#调整学习率"></a> 调整学习率</h2><p>调整学习率是最直观的一种优化思路。学习率过小会导致学习反馈不足，学习率过大会导致学习反馈震荡。下图所示是我们所实现神经网络的性能评分与与学习率的关系曲线。整体而言，我们应该不断调整，选择一个适中的学习率。</p><p><img src="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-imp-04.png?x-oss-process=image/resize,w_800" alt="" /></p><h2 id="重复训练"><a class="markdownIt-Anchor" href="#重复训练"></a> 重复训练</h2><p>重复训练也是一种经典的优化思路。我们将样本训练一次称为一个 <strong>世代</strong>（Epoch）。我们可以在样本训练的代码的外层再嵌套一层遍历，尝试进行多个世代的训练，如下所示。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># train the neural network</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># epochs is the number of times the training data set is used for training</span></span><br><span class="line">epochs = <span class="number">5</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">for</span> e <span class="keyword">in</span> <span class="built_in">range</span>(epochs):</span><br><span class="line">    <span class="keyword">for</span> record <span class="keyword">in</span> training_data_list:</span><br><span class="line">        <span class="comment"># split the record by the &#x27;, commas</span></span><br><span class="line">        all_values = record.split(<span class="string">&#x27;,&#x27;</span>)</span><br><span class="line">        <span class="comment"># scale and shift the inputs</span></span><br><span class="line">        inputs = (numpy.asfarray(all_values[<span class="number">1</span>:]) / <span class="number">255.0</span> * <span class="number">0.99</span>) + <span class="number">0.01</span></span><br><span class="line">        <span class="comment"># create the target output values (all 0.01, except the desired label which is 0.99)</span></span><br><span class="line">        targets = numpy.zeros(output_nodes) + <span class="number">0.01</span></span><br><span class="line">        <span class="comment"># all_values[0] is the target label for this record</span></span><br><span class="line">        targets[<span class="built_in">int</span>(all_values[<span class="number">0</span>])] = <span class="number">0.99</span></span><br><span class="line">        n.train(inputs, targets)</span><br><span class="line">        <span class="keyword">pass</span></span><br><span class="line">    <span class="keyword">pass</span></span><br></pre></td></tr></table></figure><p>与学习率类似，过少的训练会导致学习反馈不足，过多的训练会导致网络过渡拟合训练数据。下图所示是性能评分与世代数的关系曲线，我们同样也需要进行不断调整世代数，寻找一个最佳值。</p><p><img src="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-imp-05.png?x-oss-process=image/resize,w_800" alt="" /></p><h2 id="调整网络结构"><a class="markdownIt-Anchor" href="#调整网络结构"></a> 调整网络结构</h2><p>调整网络结构也是一种优化思路。我们可以考虑调整隐藏层的节点数量。如果节点数太少的话，会导致节点无法承载过多的特征，从而表现不佳。下图所示是性能评测与隐藏层节点数之间的关系曲线，很显然节点越多，性能表现越好。当然这也是有代价的，节点数越多，神经网络的计算量就越大。</p><p><img src="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-imp-06.png?x-oss-process=image/resize,w_800" alt="" /></p><h1 id="总结"><a class="markdownIt-Anchor" href="#总结"></a> 总结</h1><p>本文基于神经网络的基本原理，使用 Python 依次实现了神经网络的初始化、训练、查询等部分。然后，我们使用 MNIST 的样本数据和测试数据依次来对神经网络进行训练和测试。最后，我们提了三种神经网络性能优化的思路，包括：学习率、训练量、网络结构等。</p><p>后续有时间，我们将再来深入学习一下机器学习的其他相关技术。</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;前一篇 &lt;a href=&quot;https://chuquan.me/2024/07/21/neural-network-introduce&quot;&gt;文章&lt;/a&gt; 我们介绍了人工神经网络的基本原理，本文我们将使用 Python 来实现一个简易的神经网络，可用于识别手写数字，从而加深对于神经网络的理解。&lt;/p&gt;</summary>
    
    
    
    <category term="机器学习" scheme="http://chuquan.me/categories/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0/"/>
    
    
    <category term="epoch" scheme="http://chuquan.me/tags/epoch/"/>
    
    <category term="MNIST" scheme="http://chuquan.me/tags/MNIST/"/>
    
    <category term="手写数字识别" scheme="http://chuquan.me/tags/%E6%89%8B%E5%86%99%E6%95%B0%E5%AD%97%E8%AF%86%E5%88%AB/"/>
    
  </entry>
  
  <entry>
    <title>初识人工神经网络（1）——基本原理</title>
    <link href="http://chuquan.me/2024/07/21/neural-network-introduce/"/>
    <id>http://chuquan.me/2024/07/21/neural-network-introduce/</id>
    <published>2024-07-21T05:30:55.000Z</published>
    <updated>2024-07-31T15:20:26.250Z</updated>
    
    <content type="html"><![CDATA[<p>最近看了《Python神经网络编程》一书之后，对于神经网络的基本原理有了一个初步的理解，于是产出此篇文章作为系统性的梳理和总结。</p><span id="more"></span><h1 id="概述">概述</h1><p>计算机设计的初衷是为了解决大量的数学运算，因此适用于解决具有固定模式或计算步骤的问题。对于不具备固定模式或计算步骤的问题，比如图像识别、语音识别等，传统的计算机程序很难有效地予以解决。</p><p>人工智能的重要研究方向之一就是通过计算机来解决这类相对困难的问题。机器学习便是人工智能领域中的一个重要分支，而人工神经网络则是机器学习中一种被广泛使用的算法。</p><h1 id="分类器">分类器</h1><p>为了能够理解神经网络的核心思想，我们先来介绍一个分类器的例子。</p><p>假如花园中有两种虫子：毛虫细而长，瓢虫宽而短。我们希望设计一个分类器，当给定一个虫子的长度和宽度，分类器能够自动进行分类。</p><h2 id="设计分析">设计分析</h2><p>我们考虑对虫子的长度和宽度进行分析，绘制一个二维坐标系，可以发现两种虫子是存在一定的聚类特征的，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-01.png?x-oss-process=image/resize,w_800" /></p><p>因此，分类器的设计目标就是通过数据训练进行学习，从而找到一条分界线，将两种类型的虫子进行有效分类。如下所示，在训练阶段，分界线会不断地进行修正，最终到达一个相对正确的位置。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-02.png?x-oss-process=image/resize,w_800" /></p><p>分类器最终结构如下所示，它有一个输入和一个输出，通过分类器实现内部分类逻辑，最终输出分类结果。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-11.png?x-oss-process=image/resize,w_800" /></p><h2 id="样本训练">样本训练</h2><p>那么分类器该如何通过数据训练来调整分界线的斜率呢？首先，我们需要定义一个用于表示分界线的函数（或称测试函数），如下所示。</p><span class="math display">\[\begin{aligned}y = Ax\end{aligned}\]</span><p>然后，我们随机初始化 <span class="math inline">\(A\)</span>的值，假设初始化值为 <code>0.25</code>，那么将得到如下所示的直线 <spanclass="math inline">\(y = 0.25x\)</span>。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-03.png?x-oss-process=image/resize,w_800" /></p><p>接下来，我们开始输入训练样本，如下所示。当输入两个虫子的样本数据后，我们发现分界线并没有正确对虫子进行有效分类。此时，我们需要对斜率进行调整，这也是训练的核心目标。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-04.png?x-oss-process=image/resize,w_800" /></p><p>我们观察第一个训练样本数据：宽度 3.0，长度 1.0，瓢虫。我们将 <spanclass="math inline">\(x = 3.0\)</span> 代入函数 <spanclass="math inline">\(y = Ax\)</span>，得到 <spanclass="math inline">\(y = 0.25 * 3.0 =0.75\)</span>。然而，样本数据告诉我们 <span class="math inline">\(y =1.0\)</span>，此时我们看到了误差的存在。值得注意的是，分界线是为了对实现分类，我们需要让<span class="math inline">\(x\)</span> 代入函数后得到的 <spanclass="math inline">\(y\)</span> 值大于 <spanclass="math inline">\(1.0\)</span>。为了避免调整过大，我们将 <spanclass="math inline">\(y\)</span> 的目标值设置为 <spanclass="math inline">\(1.1\)</span>。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-05.png?x-oss-process=image/resize,w_800" /></p><p>由此，我们计算输出值和目标值之间的误差，基于此计算出调整的斜率差值。下图显示了初始分界线和一次训练后的分界线。</p><span class="math display">\[\begin{aligned}实际值: &amp; y = Ax = 0.25 * 3.0 = 0.75\\目标值: &amp; t = (A + \Delta A)x = 1.1\\误差值: &amp; E = t - y = (\Delta A)x = 1.1 - 0.75 = 0.35\\斜率差值: &amp; \Delta A = E / x = 0.35 / 3.0 = 0.1167\\斜率修正值: &amp; (A + \Delta A) = 0.25 + 0.1167 = 0.3667\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-06.png?x-oss-process=image/resize,w_800" /></p><p>然后，我们再观察第二个训练样本数据：宽度 1.0，长度3.0，毛虫。采用类似的方法，我们可以计算出调整后的斜率为 <spanclass="math inline">\(2.9\)</span>。下图显示了初始分界线和两次训练后的分界线。</p><span class="math display">\[\begin{aligned}实际值: &amp; y = Ax = 0.3667 * 1.0 = 0.3667\\目标值: &amp; t = (A + \Delta A)x = 2.9\\误差值: &amp; E = t - y = (\Delta A)x = 2.9 - 0.3667 = 2.5333\\斜率差值: &amp; \Delta A = E / x = 2.5333 / 1.0 = 2.5333\\斜率修正值: &amp; (A + \Delta A) = 0.3667 + 2.5333 = 2.9\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-07.png?x-oss-process=image/resize,w_800" /></p><h2 id="适度改进">适度改进</h2><p>如果我们仔细观测两次训练，会发现最终改进的直线与最后一次训练样本非常匹配。这种方式实际上抛弃了所有先前训练样本的学习结果，只对最后一次训练样本进行了学习。</p><p>那么如何解决这个问题呢？一个重要的思路就是<strong>适度改进（Moderate）</strong>。</p><p>我们可以在改进公式中增加一个调节系数 <spanclass="math inline">\(L\)</span> ，也称为 <strong>学习率（LearningRate）</strong>，如下所示。</p><span class="math display">\[\begin{aligned}\Delta A = L (E / x)\end{aligned}\]</span><p>基于新的调整公式，设定学习率 <span class="math inline">\(L =0.5\)</span>，我们再来计算一下斜率的改进过程。</p><span class="math display">\[\begin{aligned}第一次训练: &amp;\\实际值: &amp; y = Ax = 0.25 * 3.0 = 0.75\\目标值: &amp; t = (A + \Delta A)x = 1.1\\误差值: &amp; E = t - y = (\Delta A)x = 1.1 - 0.75 = 0.35\\斜率差值: &amp; \Delta A = L (E / x) = 0.5 * 0.35 / 3.0 = 0.0583\\斜率修正值: &amp; (A + \Delta A) = 0.25 + 0.0583 = 0.3083\\\\第二次训练: &amp;\\实际值: &amp; y = Ax = 0.3083 * 1.0 = 0.3083\\目标值: &amp; t = (A + \Delta A)x = 2.9\\误差值: &amp; E = t - y = (\Delta A)x = 2.9 - 0.3083 = 2.5917\\斜率差值: &amp; \Delta A = L (E / x) = 0.5 * 2.5917 / 1.0 = 1.2958\\斜率修正值: &amp; (A + \Delta A) = 0.3083 + 1.2958 = 1.6042\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-08.png?x-oss-process=image/resize,w_800" /></p><h2 id="分类器组合">分类器组合</h2><p>上述，我们介绍了单一分类器通过训练样本进行学习调整相关参数，最终可用于解决特定问题。</p><p>然而，现实中很多问题并不是一个分类器能够解决的，比如：如何在网格节点中输出逻辑异或（XOR）的值？</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-09.png?x-oss-process=image/resize,w_800" /></p><p>此时，我们无论如何都无法通过一条分界线来正确进行分类。于是，我们开始考虑采用多个分类器进行组合，共同完成对复杂问题的求解，这就是神经网络的基本思想。</p><h1 id="神经网络">神经网络</h1><p>人工神经网络（Artificial NeuralNetwork，ANN，简称神经网络），其设计思想借鉴了动物大脑的生物神经网络，构建了一套类似神经元互连的分层组织结构。</p><h2 id="神经元">神经元</h2><p>无论是人工神经网路，还是生物神经网络，神经元都是其中的基本组成单元，两种神经元的结构也基本差不多。</p><h3 id="生物神经元">生物神经元</h3><p>如下图所示，生物神经元主要包含三部分：</p><ul><li>树突：用于接收外部电信号</li><li>轴突：用于传导电信号</li><li>突触：用于将电信号传递至其他神经元或细胞</li></ul><p>此外，神经元还会通过阈值（threshold）抑制输入，直到电信号超出阈值，才会触发输入。因为神经元不希望传递各种微小的噪音信号，而只传递有意识的明显信号。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-13.png?x-oss-process=image/resize,w_800" /></p><h3 id="人工神经元">人工神经元</h3><p>如下图所示，人工神经元主要包含三个部分：</p><ul><li>输入：类似于树突，可以包含一个或多个输入端，用于接收外部信号</li><li>输出：类似于轴突和突触，用于传递和输出信号</li><li>节点：类似于细胞核，用于处理信号。本质上是两个函数，分别是：<ul><li>求和函数：对所有输入进行求和</li><li>激活函数：或称阈值函数，用于过滤噪音信号，类似于生物神经元中抑制噪音电信号。</li></ul></li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-12.png?x-oss-process=image/resize,w_800" /></p><p>对于激活函数，一个简单的阶跃函数即可实现类似的效果。但是为了更接近自然的效果，这里采用一种平滑且经典的Sigmoid 函数（简称 S函数）作为激活函数，其函数表达式如下所示。除此之外，神经网络中常用的激活函数还有很多，比如：双曲正切函数（HyperbolicTangent）、ReLU函数（Rectified Linear Unit）、LeakyReLU函数、ELU函数（Exponential Linear Unit）、SELU（Scaled ExponentialLinear Unit）、Softmax 等等，有兴趣的朋友可以自行了解。</p><span class="math display">\[\begin{aligned}y = \frac{1}{1+e^{-x}}\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-16.png?x-oss-process=image/resize,w_800" /></p><h2 id="基本结构">基本结构</h2><p>通过构建多层神经元，每一层中的神经元都与在其前后层中的所有神经元相互连接，即可得到一个分层的人工神经网络。</p><p>根据分层所在的位置，我们将分层分为三种类型：输入层、隐藏层、输出层。任意一个神经网络的输入层和输出层各自只有一个，隐藏层可以有多个。下图所示是一个三层结构的神经网络，可以看到每个节点都与前一层或后一层的其他每个节点相互连接。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-14.png?x-oss-process=image/resize,w_800" /></p><p>基于训练样本，神经网络会进行学习，那么它有没有可以调整的参数呢？类似上文介绍的分类器，我们可以调整斜率参数。在神经网络中，则是<strong>通过调整节点之间的连接强度作为训练样本的学习反馈</strong>。</p><p>下图所示展示了节点之间各个连接的连接强度，使用<strong>权重</strong>（Weight）表示，比如：<spanclass="math inline">\(w_{1,2}\)</span> 表示当前层节点 1 与后一层节点 2之间的连接强度。通常权重值的范围位于 <span class="math inline">\([0,1]\)</span> 之间，当权重值为 0 时，则表示连接断开。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-15.png?x-oss-process=image/resize,w_800" /></p><h1 id="信号转换">信号转换</h1><p>了解了神经网络的基本结构之后，我们来介绍一下输入信号是如何在神经网络中经过一层一层的神经元，最终转换成输出信号的。在这个过程中，我们会结合矩阵运算来进行表达。</p><p>下图所示是一个具有 3 个分层，每个分层 3个节点的神经网络，为了保持图示清晰，我们没有标注所有权重。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-17.png?x-oss-process=image/resize,w_800" /></p><p>下面，我们来依次看一下输入层、隐藏层、输出层对于信号的转换和处理。</p><h2 id="输入层">输入层</h2><p>在人工神经网络中，输入层的节点通常不会进行求和函数和激活函数的处理。输入层的节点主要负责接收原始的输入信号，并将其直接透传给下一层的隐藏层或输出层。</p><p>对于输入层，我们可以使用矩阵 <spanclass="math inline">\(I_{input}\)</span> 来表示输入信号，使用矩阵 <spanclass="math inline">\(O_{input}\)</span>表示输出信号。由此得到如下所示表示：</p><span class="math display">\[\begin{aligned}O_{input} = I_{input} =\left(\begin{matrix}0.9\\0.1\\0.8\end{matrix}\right)\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-18.png?x-oss-process=image/resize,w_800" /></p><h2 id="隐藏层">隐藏层</h2><p>对于隐藏层，我们可以使用矩阵 <spanclass="math inline">\(W_{input\_hidden}\)</span>来表示输入层与隐藏层之间的连接权重，如下所示：</p><span class="math display">\[\begin{aligned}W_{input\_hidden} =\left(\begin{matrix}0.9 &amp; 0.3 &amp; 0.4\\0.2 &amp; 0.8 &amp; 0.2\\0.1 &amp; 0.5 &amp; 0.6\end{matrix}\right)\end{aligned}\]</span><p>隐藏层神经元节点接收输入信号后，会依次使用求和函数、激活函数进行处理，然后进行输出。关于求和函数，我们可以使用矩阵点乘来表示，这里使用<span class="math inline">\(X_{hidden}\)</span>来表示隐藏层求和函数的计算结果。</p><span class="math display">\[\begin{aligned}X_{hidden} = &amp; W_{input\_hidden} \cdot O_{input}\\= &amp;\left(\begin{matrix}0.9 &amp; 0.3 &amp; 0.4\\0.2 &amp; 0.8 &amp; 0.2\\0.1 &amp; 0.5 &amp; 0.6\end{matrix}\right)\cdot\left(\begin{matrix}0.9\\0.1\\0.8\end{matrix}\right)\\= &amp;\left(\begin{matrix}1.16\\0.42\\0.62\end{matrix}\right)\end{aligned}\]</span><p>关于激活函数，我们使用 <span class="math inline">\(sigmoid\)</span>来表示，隐藏层的最终输出仍然可以矩阵来表示，这里使用 <spanclass="math inline">\(O_{hidden}\)</span> 表示隐藏层的最终输出。</p><span class="math display">\[\begin{aligned}O_{hidden} = &amp; sigmoid \left( X_{hidden} \right)\\= &amp;sigmoid\left(\begin{matrix}1.16\\0.42\\0.62\end{matrix}\right)\\= &amp;\left(\begin{matrix}0.761\\0.603\\0.650\end{matrix}\right)\end{aligned}\]</span><p>由此我们得到隐藏层的输出信号，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-19.png?x-oss-process=image/resize,w_800" /></p><h2 id="输出层">输出层</h2><p>输出层对于信号的转换和处理，本质上和隐藏层没有任何区别，计算的方法和流程是一样的。</p><p>我们使用 <span class="math inline">\(W_{hidden\_output}\)</span>表示隐藏层与输出层之间的连接权重，结合隐藏层的输出 <spanclass="math inline">\(O_{hidden}\)</span>，通过矩阵点乘来运用求和函数，得到<span class="math inline">\(X_{output}\)</span>，如下所示。</p><span class="math display">\[\begin{aligned}X_{output} = &amp; W_{hidden\_output} \cdot O_{hidden}\\= &amp;\left(\begin{matrix}0.3 &amp; 0.7 &amp; 0.5\\0.6 &amp; 0.5 &amp; 0.2\\0.8 &amp; 0.1 &amp; 0.9\end{matrix}\right)\cdot\left(\begin{matrix}0.761\\0.603\\0.650\end{matrix}\right)\\= &amp;\left(\begin{matrix}0.975\\0.888\\1.254\end{matrix}\right)\end{aligned}\]</span><p>最后再应用激活函数，得到输出层的结果 <spanclass="math inline">\(O_{output}\)</span> ，如下所示。</p><span class="math display">\[\begin{aligned}O_{output} = &amp; sigmoid \left( X_{output} \right)\\= &amp;sigmoid\left(\begin{matrix}0.975\\0.888\\1.254\end{matrix}\right)\\= &amp;\left(\begin{matrix}0.726\\0.708\\0.778\end{matrix}\right)\end{aligned}\]</span><p>由此我们得到输出层的输出信号，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-20.png?x-oss-process=image/resize,w_800" /></p><h1 id="训练反馈">训练反馈</h1><p>在实际应用神经网络之前，我们必须使用大量训练样本对其进行训练，训练的核心目的是<strong>调整各节点之间链接权重的值</strong>，使其调整为合适的值，从而让神经网络能够输出相对准确的结果。</p><h2 id="值的约束">值的约束</h2><p>首先我们来看下神经网络中的值，其主要包含四种：目标值、输入值、输出值、权重值。这些值的范围与神经网络运行和训练密切相关，下面我们分别来了解一下。</p><h3 id="输出值-目标值">输出值 &amp; 目标值</h3><p>输出值的范围与激活函数有关。下图所示是 Sigmoid激活函数的曲线图，其输出值的范围为 <span class="math inline">\((0,1)\)</span>。训练样本的目标值的范围也应该与输出值的范围一样；否则，神经网络会驱使更大（或更小）的权重，导致学习能力过犹不足。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-22.png?x-oss-process=image/resize,w_800" /></p><h3 id="输入值">输入值</h3><p>类似输出值，输入值范围也与激活函数有关。对于 sigmoid函数，当输入值超出某个范围之后，输出值会变得非常平坦，换句话说就是梯度（斜率）差异很小，从而会导致学习能力降低。</p><p>很显然，梯度变化适中的区域更适合用于神经网络的训练，如下图所示，输入值的范围在<span class="math inline">\([-1, 1]\)</span>之内的激活函数梯度变化适中。不过，我们要注意输入值为 0的情况，此时结合任意权重值后，信号都会变成 0，权重更新表达式也会变成0，从而导致学习能力丧失（后续权重更新中会详细进行介绍）。因此我们要避免输入值等于0，一般建议将输入值的范围设置为 <span class="math inline">\((0,1]\)</span>。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-21.png?x-oss-process=image/resize,w_800" /></p><h3 id="权重值">权重值</h3><p>根据前面的介绍，我们知道权重值会影响求和函数的结果，并最终作为激活函数的输入。因此，初始权重值同样也会影响神经网络的学习能力，过大（或过小）的初始值会造成过大（或过小）的信号传递给激活函数，导致神经网络饱和，从而降低神经网络学习更好权重的能力。</p><p>如下所示，是我们认为 sigmoid激活函数的梯度适中的区域。对此，我们也可以简单地将权重的初始值范围设置为<span class="math inline">\([-1, 1]\)</span>，并随机均匀进行取值。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-23.png?x-oss-process=image/resize,w_800" /></p><p>当然，我们还有更好的权重值初始化方案。对此，我们回顾一下求和函数的输入和输出。一个神经元节点的求和函数输出等于与之相连的前一层所有节点的输出的加权求和。因此我们应该根据输出值的范围逆向推导各个连接的权重值，很显然，这与每个节点的连接的节点数量有关。对此，数学家提出了一种基于经验法则的权重初始化方案，即<strong>在一个节点传入连接数量平方根倒数的大致范围内随机采样，作为权重初始值</strong>。比如，每个节点具有100 条输入连接，那么权重的范围应该在 <spanclass="math inline">\([-1/\sqrt{100}, 1/\sqrt{100}]\)</span> 之间，即<span class="math inline">\([-0.1, 0.1]\)</span> 之间。</p><p>从直觉上讲，这种方案是有意义的。一个节点的输入连接越多，就会有越多的信号叠加在一起。因此，如果连接更多，那么减小权重的范围是有道理的。</p><p>当然，这种优化方案还定义了权重初始值应该遵循正态分布，下图总结了这种基于正态分布的权重初始化方案。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-24.png?x-oss-process=image/resize,w_800" /></p><h2 id="误差分割">误差分割</h2><p>神经网络是基于训练样本的目标值与实际运行的输出值之间的误差来进行反馈学习，从而调整各个连接的权重。然而，每个节点可能有多个输入连接，每个连接有各自的权重值。为了合理分配误差值，神经网络会根据连接的权重来进行分割误差。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-25.png?x-oss-process=image/resize,w_800" /></p><h2 id="误差传播">误差传播</h2><p>了解了误差分割后，我们再来看看神经网络是如何传播误差的。</p><h3 id="输出层-1">输出层</h3><p>我们先来看输出层的误差传播。下图展示了一个具有 2 个输入节点和 2个输出节点的神经网络。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-26.png?x-oss-process=image/resize,w_800" /></p><p>对于输出层节点 1，我们定义其实际输出值为 <spanclass="math inline">\(o_1\)</span>，目标输出值为 <spanclass="math inline">\(t_1\)</span>，那么由此可计算得出误差值 <spanclass="math inline">\(e_1 = (t_1 -o_1)\)</span>。然后我们可以按照连接权重来分割误差，很显然，<spanclass="math inline">\(e_1\)</span> 和 <spanclass="math inline">\(e_2\)</span> 的误差传播组成如下所示。</p><span class="math display">\[\begin{aligned}e_1 = e_1 \frac{w_{1,1}}{w_{1,1} + w_{2,1}} + e_1 \frac{w_{2,1}}{w_{1,1}+ w_{2,1}}\\\\e_2 = e_2 \frac{w_{1,2}}{w_{1,2} + w_{2,2}} + e_2 \frac{w_{2,2}}{w_{1,2}+ w_{2,2}}\end{aligned}\]</span><h3 id="隐藏层-1">隐藏层</h3><p>我们再来看隐藏层的误差传播。下图展示了一个包含输入层、隐藏层、输出层的3 层神经网络。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-27.png?x-oss-process=image/resize,w_800" /></p><p>对于隐藏层的各个节点，它们并没有所谓的目标值，因此无法直接计算其误差。对此，我们可以通过将输出层的误差进行反向传播，层层传递。</p><p>首先，我们根据输出层的误差传播可以计算等到输出层前置连接 <spanclass="math inline">\(w_{1,1}\)</span>、<spanclass="math inline">\(w_{1,2}\)</span>、<spanclass="math inline">\(w_{2,1}\)</span>、<spanclass="math inline">\(w_{2,2}\)</span>各自的误差分量，然后即可计算得到隐藏层节点的误差值 <spanclass="math inline">\(e_1\)</span>、<spanclass="math inline">\(e_2\)</span>。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-29.png?x-oss-process=image/resize,w_800" /></p><p>由此方法继续计算，可以进一步得出隐层层前置连接各自的误差分量，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-28.png?x-oss-process=image/resize,w_800" /></p><h3 id="矩阵运算">矩阵运算</h3><p>对于各个层的误差值的计算，我们同样可以使用矩阵来表示。我们使用 <spanclass="math inline">\(error_{output}\)</span> 来表示输出层误差，使用<span class="math inline">\(error_{hidden}\)</span>来表示隐藏层误差，其表示如下。</p><span class="math display">\[\begin{aligned}error_{output} = &amp;\left(\begin{matrix}e_1\\e_2\end{matrix}\right)\\\\error_{hidden} = &amp;\left(\begin{matrix}\frac{w_{1,1}}{w_{1,1} + w_{2,1}} &amp; \frac{w_{1,2}}{w_{1,2} +w_{2,2}}\\\frac{w_{2,1}}{w_{2,1} + w_{1,1}} &amp; \frac{w_{2,2}}{w_{2,2} +w_{1,2}}\end{matrix}\right)\cdot\left(\begin{matrix}e_1\\e_2\end{matrix}\right)\end{aligned}\]</span><p><span class="math inline">\(error_{hidden}\)</span>的矩阵定义比较复杂，有没有办法进行简化呢？我们观察到，最终要的是输出误差与链接权重<span class="math inline">\(w_{i,j}\)</span>的乘法。更大的权重意味着更多的输出误差给隐藏层。这里的分母只是一种归一化因此，如果我们忽略这个因子，那么我们仅仅时区了误差的大小，但是能够换来更加简单的矩阵表示，如下所示。</p><span class="math display">\[\begin{aligned}error_{hidden} = &amp;\left(\begin{matrix}w_{1,1} &amp; w_{1,2}\\w_{2,1} &amp; w_{2,2}\end{matrix}\right)\cdot\left(\begin{matrix}e_1\\e_2\end{matrix}\right)\end{aligned}\]</span><p>对比之前的权重矩阵 <spanclass="math inline">\(W_{hidden\_output}\)</span>，可以发现这里的矩阵其实就是<span class="math inline">\(W_{hidden\_output}\)</span>的转置矩阵，因此我们可以将表示式进一步转换成如下所示。</p><span class="math display">\[\begin{aligned}error_{hidden} = W^T_{hidden\_output} \cdot error_{output}\end{aligned}\]</span><h2 id="权重更新">权重更新</h2><p>了解了误差分割与传播后，我们再来看看神经网络是如何通过误差来对权重进行更新，从而达到学习的目的。</p><p>事实上，我们很容易就可以基于神经网络的正向信号转换过程，推导出反向权重更新过程。由于正向过程中，神经元对信号依次进行求和函数、激活函数的处理，并且层与层之间是相互依赖的。对于一个3 x 3的神经网络，输出层的某个节点的计算公式将非常复杂，如下所示。一旦神经玩网络的节点数量、层级数量增加，计算公式会更加复杂，基于此进行逆向的权重更新，将会变得极其复杂！</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-30.png?x-oss-process=image/resize,w_800" /></p><h3 id="梯度下降法">梯度下降法</h3><p>为此，研究人员提出了一种 <strong>梯度下降</strong>（GradientDescent） 的方法来绕过这个问题，解决这个问题。</p><p>我们来举一个例子说明一下说明是梯度下降法。想象一下，一个非常复杂、有山峰山谷的群山峻岭。在黑夜中，伸手不见五指。此时，你在某个山坡上，需要到坡底，手里只有一把手电筒，你该怎么做呢？你只能通过手电筒看到脚下的土地是上坡还是下坡，于是你就小步地往这个方向走。通过这种方式，不需要完整的底图，也不需要事先指定路线，缓慢地前进，慢慢地下山。这种方法，在数学上被称为梯度下降。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-31.png?x-oss-process=image/resize,w_800" /></p><p>那么梯度下降法与神经网络有什么关系呢？其实，我们可以寻找一个误差函数<span class="math inline">\(y = f(x)\)</span>，<spanclass="math inline">\(y\)</span> 表示误差值，<spanclass="math inline">\(x\)</span>表示连接的权重值。对于神经网络的样本训练而言，本质上就是找到最小误差值所对应的权重值，从而更新权重，达到学习的目的。</p><p>为了正确理解梯度下降的思想，我们来使用一个简单的例子来演示一下。假设，误差函数为<span class="math inline">\(y = (x-1)^2 + 1\)</span>。我们希望找到 <spanclass="math inline">\(x\)</span>，从而最小化误差 <spanclass="math inline">\(y\)</span>。那么我们可以通过判断斜率（也称梯度）的方式来寻找，当斜率为负时，<spanclass="math inline">\(x\)</span> 可以尝试适当增大；当斜率为正时，<spanclass="math inline">\(x\)</span>可以尝试适当减小，通过这种方式逐步逼近，从而找到最小值，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-32.png?x-oss-process=image/resize,w_800" /></p><p>为了提高训练学习的效率，我们可以动态调节步长。当梯度较大时，可以使用较大的步长，提高学习效率；当梯度较小时，可以使用较小的步长，避免调整过度。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-33.png?x-oss-process=image/resize,w_800" /></p><p>实际情况下，神经网络中的误差函数并不一定那么简单，它可能会有多个局部最小值。为了避免终止于错误的函数最小值，我们可以使用不同的起点（权重初值值）来进行多次训练。如下图所示，我们使用梯度下降法进行了三次尝试，其中有一次终止于错误的最小值。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-34.png?x-oss-process=image/resize,w_800" /></p><h3 id="误差函数">误差函数</h3><p>神经网络具有非常多的节点和连接，那么我们该如何使用误差函数来描述这么多的连接权重并实现权重更新呢？这里我们针对每一个层都会使用一个误差函数来批量描述连接权重与误差的关系。</p><h4 id="误差表示">误差表示</h4><p>由于每一个层都包含多个节点，我们需要对每个节点的误差进行聚合。此时需要考虑多个误差值的情况下如何表示总体误差，对此下面列出了针对3 个输出节点的神经网络的几种误差表示方法。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-35.png?x-oss-process=image/resize,w_800" /></p><p>方案一：误差 = 目标值 -实际值。这种方案描述误差的方法非常直观，但是在处理多节点的误差聚合时，会出现总和为0 的情况，这会导致神经网络无法得到很好的训练。</p><p>方案二：误差 = |目标值 -实际值|。这种方案解决了多节点的误差聚合可能为 0的问题。但是它的斜率（或称梯度），在最小值附近会出现跳变，从而导致梯度下降法无法很好地发挥作用。下图所示对应的函数及其导数。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-36.png?x-oss-process=image/resize,w_800" /></p><p>方案三：误差 = (目标值 -实际值)^2。这种方案即解决了多节点的误差聚合可能为 0的问题，也解决了斜率跳变的问题。因此，我们采用这种方案作为误差的计算方式。</p><h4 id="斜率推导">斜率推导</h4><p>要使用梯度下降法，我们必须要计算出误差函数相对权重的斜率，这里涉及到微积分的知识。这里如果你对微积分不太熟悉也没关系，我们只要知道最后推导出来的公式即可。</p><p>下图所示分别展示了包含一个权重和两个权重的误差函数及其斜率的示意图。当误差函数中每增加一个权重变量时，函数就会增加一个维度。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-37.png?x-oss-process=image/resize,w_800" /></p><p>下面，我们先来看隐藏层和输出层之间的连接权重。我们使用 <spanclass="math inline">\(w_{i,k}\)</span>表示隐藏层和输出层之间的连接权重，使用 <spanclass="math inline">\(n\)</span> 表示输层节点的数量，然后对误差函数<span class="math inline">\(E\)</span> 进行展开，得到如下表示。</p><span class="math display">\[\begin{aligned}\frac{\partial E}{\partial w_{j,k}} = \frac{\partial \sum_n(t_n -o_n)^2}{\partial w_{j,k}}\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-38.png?x-oss-process=image/resize,w_800" /></p><p>注意，在节点 <span class="math inline">\(n\)</span> 的输出 <spanclass="math inline">\(o_n\)</span>只取决于链接到这个节点的连接。对于单个输出节点 <spanclass="math inline">\(k\)</span>，其只依赖于与它连接的节点及其权重。因此，我们可以进一步简化表达式，如下所示。</p><span class="math display">\[\begin{aligned}\frac{\partial E}{\partial w_{j,k}} = \frac{\partial (t_k -o_k)^2}{\partial w_{j,k}}\end{aligned}\]</span><p>为了继续推导，我们利用链式法则对斜率表达式进行拆分，进而推导，如下所示。</p><span class="math display">\[\begin{aligned}\frac{\partial E}{\partial w_{j,k}} = &amp;\frac{\partial E}{\partial o_k} \cdot  \frac{\partial o_k}{\partialw_{j,k}} \\= &amp;-2(t_k - o_k) \cdot \frac{\partial o_k}{\partial w_{j,k}} \\\end{aligned}\]</span><p>输出层的输出 <span class="math inline">\(o_k\)</span>依赖隐藏层的输出 <spanclass="math inline">\(o_j\)</span>，然后经过输出层节点应用求和函数、激活函数，从而转换成<span class="math inline">\(o_k\)</span>。因此，我们可以继续推导。</p><span class="math display">\[\begin{aligned}\frac{\partial E}{\partial w_{j,k}} = &amp;\frac{\partial E}{\partial o_k} \cdot  \frac{\partial o_k}{\partialw_{j,k}} \\= &amp;-2(t_k - o_k) \cdot \frac{\partial o_k}{\partial w_{j,k}} \\= &amp;-2(t_k - o_k) \cdot \frac{\partial sigmoid(\sum_j w_{j,k} \cdoto_j)}{\partial w_{j, k}} \\\end{aligned}\]</span><p>接下来涉及到微分 sigmoid函数，这里我们直接使用数学家们已经推导出来的结果进行应用，如下所示。</p><span class="math display">\[\begin{aligned}\frac{\partial sigmoid(x)}{\partial x} = sigmoid(x) (1 - sigmoid(x))\end{aligned}\]</span><p>将该结果代入上述斜率推导中，得到：</p><span class="math display">\[\begin{aligned}\frac{\partial E}{\partial w_{j,k}} = &amp;-2(t_k - o_k) \cdot sigmoid(\sum_j w_{j,k} \cdot o_j) (1- sigmoid(\sum_jw_{j,k} \cdot o_j)) \cdot \frac{\partial \sum_j w_{j,k} \cdoto_j}{\partial w_{j, k}} \\= &amp;-2(t_k - o_k) \cdot sigmoid(\sum_j w_{j,k} \cdot o_j) (1- sigmoid(\sum_jw_{j,k} \cdot o_j)) \cdot o_j\end{aligned}\]</span><p>由于在梯度下降法中，我们只关注斜率的方向，所以可以进一步去掉系数<code>2</code>，从而得到如下斜率表达式。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-39.png?x-oss-process=image/resize,w_800" /></p><p>由于权重改变方向与梯度方向相反，结合学习率 <spanclass="math inline">\(\alpha\)</span>，我们可以得到权重更新前后的关系式，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-40.png?x-oss-process=image/resize,w_800" /></p><h4 id="矩阵表示">矩阵表示</h4><p>为了推导矩阵表示，我们尝试使用矩阵乘法的形式进行计算，可以得到如下所示的表示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-41.png?x-oss-process=image/resize,w_800" /></p><p>我们可以发现矩阵表达式中的最后一部分，其实就是前一层 <spanclass="math inline">\(o_j\)</span>的输出的转置。最后，我们可以得到权重更新矩阵的矩阵表达式，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/neural-network-intro-42.png?x-oss-process=image/resize,w_800" /></p><h1 id="总结">总结</h1><p>本文我们主要介绍了人工神经网络工作的基本原理，从而能够对它产生一个初步认知，有利于后续的进阶学习。</p><p>首先，我们以一个分类器结构介绍了机器学习的基本思想。在此基础上进行扩展，组合多个分类器，这也是人工神经网络的基本思想。</p><p>然后，我们介绍了神经网络正向的信号转换过程，其中涉及连接权重、求和函数、激活函数等。此外，我们还介绍了神经网络的训练的相关概念，包括：误差分割、误差传播、权重更新等。关于权重更新，我们重点介绍了误差函数及斜率的推导。</p><p>后续有时间我们将进一步介绍如何使用 Python打造一个简单的人工神经网络。</p><h1 id="参考">参考</h1><ol type="1"><li>《Python神经网络编程》</li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;最近看了《Python
神经网络编程》一书之后，对于神经网络的基本原理有了一个初步的理解，于是产出此篇文章作为系统性的梳理和总结。&lt;/p&gt;</summary>
    
    
    
    <category term="机器学习" scheme="http://chuquan.me/categories/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0/"/>
    
    
    <category term="神经网络" scheme="http://chuquan.me/tags/%E7%A5%9E%E7%BB%8F%E7%BD%91%E7%BB%9C/"/>
    
    <category term="激活函数" scheme="http://chuquan.me/tags/%E6%BF%80%E6%B4%BB%E5%87%BD%E6%95%B0/"/>
    
    <category term="梯度下降" scheme="http://chuquan.me/tags/%E6%A2%AF%E5%BA%A6%E4%B8%8B%E9%99%8D/"/>
    
  </entry>
  
  <entry>
    <title>基于 Threejs 实现 3D 魔方</title>
    <link href="http://chuquan.me/2024/06/01/rubiks-cube-01/"/>
    <id>http://chuquan.me/2024/06/01/rubiks-cube-01/</id>
    <published>2024-06-01T09:54:33.000Z</published>
    <updated>2024-06-01T11:26:13.014Z</updated>
    
    <content type="html"><![CDATA[<p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rubiks-cube-12.png?x-oss-process=image/resize,w_800" /></p><span id="more"></span><p>最近这段时间学习了计算机图形学和Threejs，为了巩固一下学习效果，同时也希望给<ahref="https://apps.apple.com/cn/app/%E8%8E%AB%E8%B4%9F%E4%BC%91%E6%81%AF-%E4%BC%91%E6%81%AF%E6%8F%90%E9%86%92/id6474056217?mt=12">「莫负休息」</a>新增主题，于是基于Threejs 实现了一个魔方程序。当然，基于 Threejs的魔方程序其实早就已经有了，我只不过是站在前人的成果上做了一次实践和总结而已。</p><p>源码传送门——<ahref="https://github.com/baochuquan/rubiks-cube">Rubiks Cube</a>，Demo传送门——<a href="http://rubiks.chuquan.me">rubiks.chuquan.me</a>。</p><h1 id="魔方的定义">魔方的定义</h1><p>魔方（Rubik's Cube），是匈牙利建筑学教授和雕塑鲁比克·埃尔内，于 1974年发明的机械益智玩具。</p><p>魔方是一个正立方体，一共 6 个面，对应 6种颜色。魔方的官方配色是：白色、红色、橙色、黄色、绿色、蓝色，其中黄白相对，红橙相对，蓝绿相对，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rubiks-cube-01.png?x-oss-process=image/resize,w_800" /></p><p>一个三阶魔方由 3 x 3 x 3 共 27 个方块组成，根据方块的位置，可以分为 3种类型，分别是：</p><ul><li>中心块：中心块有 6个，位于魔方每面的正中心，只有一种颜色。中心块彼此之间的相对位置不会变化。</li><li>棱块：棱块有 12个，位于魔方每个魔方中心块的上下左右，有两种颜色。</li><li>角块：角块有 8 个，位于魔方每个魔方中心块的斜对角，有三种颜色。</li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rubiks-cube-02.png?x-oss-process=image/resize,w_800" /></p><h1 id="场景布置">场景布置</h1><p>对于任意 3D场景，我们都需要先对场景中的基本元素进行设置，主要包括：相机、灯光、渲染器。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rubiks-cube-03.png?x-oss-process=image/resize,w_800" /></p><p>首先初始化一个场景<code>Scene</code>，后续所有相关元素都将添加至这个场景中，并设置位置坐标。</p><p>然后，我们初始化相机，Threejs中有两种相机：正交相机、透视相机。透视相机成像的画面具有近大远小的效果，所以我们这里使用透视相机。当然，相机的位置确立之后，我们还需要确定它的观测方向，这里使用<code>lookAt</code> 方法。此外，我们还可以设置相机的视场（Field ofView），它表示相机的可视角度值，决定了屏幕画面的可视范围。</p><p>对于灯光，这里我只设置了一个环境光，因此无需设置坐标。当然，Threejs中有很多光源，比如：点光源、面光源、射线光源等。</p><p>相关的代码实现如下所示。</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">let</span> scene, camera, renderer;</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">setupScene</span>(<span class="params"></span>) &#123;</span><br><span class="line">  scene = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">Scene</span>();</span><br><span class="line">  scene.<span class="property">background</span> = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">Color</span>(<span class="number">0xFFFFFF</span>);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">setupCamera</span>(<span class="params"></span>) &#123;</span><br><span class="line">  camera = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">PerspectiveCamera</span>(</span><br><span class="line">    <span class="number">45</span>,</span><br><span class="line">    <span class="variable language_">window</span>.<span class="property">innerWidth</span> / <span class="variable language_">window</span>.<span class="property">innerHeight</span>,</span><br><span class="line">    <span class="number">0.1</span>, </span><br><span class="line">    <span class="number">1000</span></span><br><span class="line">  );</span><br><span class="line">  camera.<span class="property">position</span>.<span class="title function_">set</span>(<span class="number">10</span>, <span class="number">12</span>, <span class="number">10</span>);</span><br><span class="line">  camera.<span class="title function_">lookAt</span>(<span class="number">0</span>, <span class="number">3</span>, <span class="number">0</span>);</span><br><span class="line">  camera.<span class="property">fov</span> = <span class="number">45</span></span><br><span class="line">  camera.<span class="title function_">updateProjectionMatrix</span>();</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">setupLights</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> ambientLight = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">AmbientLight</span>(<span class="number">0xFFFFFF</span>);</span><br><span class="line">  scene.<span class="title function_">add</span>(ambientLight);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>最后，我们还需要定义一个渲染器。通过渲染器我们才能够将 3D场景的渲染结果并绑定至 2D平面，相关代码如下所示。在具体实现中，我们将渲染器的 DOM 元素绑定至<code>body</code> 中，这样我们才能在 2D 网页中看到渲染效果。</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">setupRenderer</span>(<span class="params"></span>) &#123;</span><br><span class="line">  renderer = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">WebGLRenderer</span>(&#123;</span><br><span class="line">    <span class="attr">antialias</span>: <span class="literal">true</span></span><br><span class="line">  &#125;);</span><br><span class="line">  renderer.<span class="title function_">setSize</span>(<span class="variable language_">window</span>.<span class="property">innerWidth</span>, <span class="variable language_">window</span>.<span class="property">innerHeight</span>);</span><br><span class="line">  <span class="variable language_">document</span>.<span class="property">body</span>.<span class="title function_">appendChild</span>(renderer.<span class="property">domElement</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>另外，为了方便查看空间效果，一般我们会创建一个轨道控制器。基于轨道控制器，我们可以通过鼠标旋转整个空间坐标系，从而可以在不同角度进行观测，相关代码如下所示。</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">let</span> controller;</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">setupControls</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="comment">// 初始化控制器</span></span><br><span class="line">  controller = <span class="keyword">new</span> <span class="title class_">OrbitControls</span>(camera, renderer.<span class="property">domElement</span>);</span><br><span class="line">  controller.<span class="property">enableDamping</span> = <span class="literal">true</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h1 id="魔方建模">魔方建模</h1><p>完成了场景布置之后，我们将在空间中对魔方进行建模。建模的过程非常简单，只需创建3 x 3 x 3 共 27个立方体即可，每个立方体的表面使用贴图作为材质。为了便于后续旋转魔方时获取同一平面中的9 个立方体，我们在建模时会对每个立方体设置编号索引，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rubiks-cube-04.png?x-oss-process=image/resize,w_800" /></p><p>魔方建模的实现代码如下所示。 <figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 创建立方体，并加入场景</span></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">setupCubes</span>(<span class="params"></span>) &#123;</span><br><span class="line">  cubes = <span class="title function_">createCube</span>(<span class="number">0</span>, <span class="number">0</span>, <span class="number">0</span>, <span class="number">3</span>, <span class="number">1</span>);</span><br><span class="line">  <span class="keyword">for</span> (<span class="keyword">var</span> i = <span class="number">0</span>; i &lt; cubes.<span class="property">length</span>; i++) &#123;</span><br><span class="line">    <span class="keyword">var</span> cube = cubes[i];</span><br><span class="line">    scene.<span class="title function_">add</span>(cube);</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 创建立方体，设置空间左边，使用贴图作为材质</span></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">createCube</span>(<span class="params">x, y, z, num, len</span>) &#123;</span><br><span class="line">  <span class="comment">// 魔方左上角坐标</span></span><br><span class="line">  <span class="keyword">var</span> leftUpX = x - num / <span class="number">2</span> * len;</span><br><span class="line">  <span class="keyword">var</span> leftUpY = y + num / <span class="number">2</span> * len;</span><br><span class="line">  <span class="keyword">var</span> leftUpZ = z + num / <span class="number">2</span> * len;</span><br><span class="line">  <span class="comment">// 根据颜色生成材质</span></span><br><span class="line">  <span class="keyword">const</span> loader = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">TextureLoader</span>();</span><br><span class="line">  <span class="keyword">const</span> textures = [</span><br><span class="line">    loader.<span class="title function_">load</span>(<span class="string">&quot;./img/blue.png&quot;</span>),</span><br><span class="line">    loader.<span class="title function_">load</span>(<span class="string">&quot;./img/green.png&quot;</span>),</span><br><span class="line">    loader.<span class="title function_">load</span>(<span class="string">&quot;./img/yellow.png&quot;</span>),</span><br><span class="line">    loader.<span class="title function_">load</span>(<span class="string">&quot;./img/white.png&quot;</span>),</span><br><span class="line">    loader.<span class="title function_">load</span>(<span class="string">&quot;./img/orange.png&quot;</span>),</span><br><span class="line">    loader.<span class="title function_">load</span>(<span class="string">&quot;./img/red.png&quot;</span>),</span><br><span class="line">  ];</span><br><span class="line">  <span class="keyword">const</span> materials = textures.<span class="title function_">map</span>(<span class="function"><span class="params">texture</span> =&gt;</span> <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">MeshBasicMaterial</span>(&#123; <span class="attr">map</span>: texture &#125;));</span><br><span class="line">  <span class="comment">// 生成小方块</span></span><br><span class="line">  <span class="keyword">var</span> cubes = [];</span><br><span class="line">  <span class="keyword">for</span> (<span class="keyword">var</span> i = <span class="number">0</span>; i &lt; num; i++) &#123;</span><br><span class="line">    <span class="keyword">for</span> (<span class="keyword">var</span> j = <span class="number">0</span>; j &lt; num * num; j++) &#123;</span><br><span class="line">      <span class="keyword">var</span> box = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">BoxGeometry</span>(len, len, len);</span><br><span class="line">      <span class="keyword">var</span> mesh = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">Mesh</span>(box, materials);</span><br><span class="line">      <span class="comment">// 依次计算各个小方块中心点坐标</span></span><br><span class="line">      mesh.<span class="property">position</span>.<span class="property">x</span> = (leftUpX + len / <span class="number">2</span>) + (j % num) * len;</span><br><span class="line">      mesh.<span class="property">position</span>.<span class="property">y</span> = (leftUpY - len / <span class="number">2</span>) - <span class="built_in">parseInt</span>(j / num) * len;</span><br><span class="line">      mesh.<span class="property">position</span>.<span class="property">z</span> = (leftUpZ - len / <span class="number">2</span>) - i * len;</span><br><span class="line">      mesh.<span class="property">tag</span> = i * <span class="number">9</span> + j;</span><br><span class="line">      cubes.<span class="title function_">push</span>(mesh);</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">  <span class="keyword">return</span> cubes;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></p><p>至此，魔方建模实现完成，完整的代码可以参考 <ahref="https://github.com/baochuquan/rubiks-cube/blob/main/src/components/RubiksCube01.vue">RubiksCube01.vue</a>文件。</p><h1 id="魔方控制">魔方控制</h1><p>魔方控制是基于鼠标实现的，核心思想分为以下几个步骤：</p><ul><li>首先，通过鼠标触点确定触点目标方块和触点平面法向量</li><li>其次，根据鼠标移动方向和触点平面法向量确定旋转方向</li><li>然后，通过旋转方向和触点目标方块获取整个旋转平面</li><li>最后，对整个旋转平面中的所有方块执行旋转动画</li></ul><h2 id="监听鼠标事件">监听鼠标事件</h2><p>鼠标事件是控制魔方的基础，因此我们需要实现鼠标事件的监听。相关实现如下所示，我们同时处理了鼠标控制和触摸控制两种情况。</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">setupEvents</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="variable language_">window</span>.<span class="title function_">addEventListener</span>(<span class="string">&#x27;mousedown&#x27;</span>, startMouse);</span><br><span class="line">  <span class="variable language_">window</span>.<span class="title function_">addEventListener</span>(<span class="string">&#x27;mousemove&#x27;</span>, moveMouse);</span><br><span class="line">  <span class="variable language_">window</span>.<span class="title function_">addEventListener</span>(<span class="string">&#x27;mouseup&#x27;</span>, stopMouse);</span><br><span class="line">  <span class="variable language_">window</span>.<span class="title function_">addEventListener</span>(<span class="string">&#x27;touchstart&#x27;</span>, startMouse);</span><br><span class="line">  <span class="variable language_">window</span>.<span class="title function_">addEventListener</span>(<span class="string">&#x27;touchmove&#x27;</span>, moveMouse);</span><br><span class="line">  <span class="variable language_">window</span>.<span class="title function_">addEventListener</span>(<span class="string">&#x27;touchend&#x27;</span>, stopMouse);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="确定触点方块与平面法向量">确定触点方块与平面法向量</h2><p>对于确定目标触点方块和平面法向量，这里有两个问题：</p><ul><li>如何通过二维平面中的鼠标位置确定三维空间中的位置呢？</li><li>立方体的位置不固定，那么该如何确定触点平面的方向呢？</li></ul><p>对于第一个问题，解决方法是<strong>射线</strong>（Raycaster），其基本原理是：通过相机位置和鼠标位置确定三维空间中的一根射线，延伸射线，找到三维空间中与射线相交的物体，根据自定义规则（比如：第一个）来找到目标物体。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rubiks-cube-05.png?x-oss-process=image/resize,w_800" /></p><p>对于第二个问题，我们首先需要了解一下 Threejs 中的坐标系统： -全局坐标系：也称世界坐标系，是整个 3D 场景的坐标系。 -局部坐标系：也称物体坐标系。在 iOS/Android 中存在视图层级树，在 Threejs中同样存在场景层级树，整个 3D场景是根场景，空间中的物体可以作为子场景，子场景又可以继续添加场景。每个场景有自己的坐标系，当对一个场景进行仿射变换，那么它的子场景也会发生仿射变换，这就是物体坐标系的作用。</p><p>由于魔方旋转过程中，每个立方体自身的也在不停的旋转和移动，此时每个物体的局部坐标系也会发生变换，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rubiks-cube-06.png?x-oss-process=image/resize,w_800" /></p><p>此时，如果基于目标立方体获取其表面法向量，那么获取到的法向量是基于局部坐标系的，不具备全局意义。因此，我们必须要将基于<strong>局部坐标系</strong> 的表面法向量转换成基于<strong>全局坐标系</strong> 的表面法向量。</p><p>对此，有两种解决方法：</p><ul><li>对基于局部坐标系的法向量通过矩阵变换，转换成基于全局坐标系。</li><li>增加一个固定不变的透明物体，通过射线获取其表面法向量，以代表立方体的表面法向量。</li></ul><p>对于前者，我们需要记录立方体从原始位置到当前位置的所有变换操作，再对基于局部坐标系的法向量做逆变换。这种方案实现难度且计算量都很大。</p><p>对于后者，其实现难度显然更低。我们只需创建一个透明的立方体，其大小与魔方整体相同，如下图所示。当判断表面法向量时，通过该透明立方体获取即可，由此得到的是基于全局坐标系的法向量。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rubiks-cube-07.png?x-oss-process=image/resize,w_800" /></p><p>如下所示为确定触点方块与平面法向量的核心代码逻辑。 <figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">setupRubiks</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="comment">// 透明正方体</span></span><br><span class="line">  <span class="keyword">let</span> box = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">BoxGeometry</span>(<span class="number">3</span>, <span class="number">3</span>, <span class="number">3</span>);</span><br><span class="line">  <span class="keyword">let</span> mesh = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">MeshBasicMaterial</span>(&#123;<span class="attr">vertexColors</span>: <span class="variable constant_">THREE</span>.<span class="property">FaceColors</span>, <span class="attr">opacity</span>: <span class="number">0</span>, <span class="attr">transparent</span>: <span class="literal">true</span>&#125;);</span><br><span class="line">  rubiks = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">Mesh</span>(box, mesh);</span><br><span class="line">  rubiks.<span class="property">cubeType</span> = <span class="string">&#x27;coverCube&#x27;</span>;</span><br><span class="line">  scene.<span class="title function_">add</span>(rubiks);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 获取操作焦点以及该焦点所在平面的法向量 </span></span><br><span class="line"><span class="comment"> * */</span> </span><br><span class="line"><span class="keyword">function</span> <span class="title function_">getIntersectAndNormalize</span>(<span class="params">event</span>) &#123;</span><br><span class="line">  <span class="keyword">let</span> mouse = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">Vector2</span>();</span><br><span class="line">  <span class="keyword">if</span> (event.<span class="property">touches</span>) &#123;</span><br><span class="line">    <span class="comment">// 触摸事件</span></span><br><span class="line">    <span class="keyword">var</span> touch = event.<span class="property">touches</span>[<span class="number">0</span>];</span><br><span class="line">    mouse.<span class="property">x</span> = (touch.<span class="property">clientX</span> / <span class="variable language_">window</span>.<span class="property">innerWidth</span>) * <span class="number">2</span> - <span class="number">1</span>;</span><br><span class="line">    mouse.<span class="property">y</span> = -(touch.<span class="property">clientY</span> / <span class="variable language_">window</span>.<span class="property">innerHeight</span>) * <span class="number">2</span> + <span class="number">1</span>;</span><br><span class="line">  &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">    <span class="comment">// 鼠标事件</span></span><br><span class="line">    mouse.<span class="property">x</span> = (event.<span class="property">clientX</span> / <span class="variable language_">window</span>.<span class="property">innerWidth</span>) * <span class="number">2</span> - <span class="number">1</span>;</span><br><span class="line">    mouse.<span class="property">y</span> = -(event.<span class="property">clientY</span> / <span class="variable language_">window</span>.<span class="property">innerHeight</span>) * <span class="number">2</span> + <span class="number">1</span>;</span><br><span class="line">  &#125; </span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> raycaster = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">Raycaster</span>();</span><br><span class="line">  raycaster.<span class="title function_">setFromCamera</span>(mouse, camera);</span><br><span class="line">  <span class="comment">// Raycaster方式定位选取元素，可能会选取多个，以第一个为准</span></span><br><span class="line">  <span class="keyword">var</span> intersects = raycaster.<span class="title function_">intersectObjects</span>(scene.<span class="property">children</span>);</span><br><span class="line">  <span class="keyword">var</span> intersect, normalize;</span><br><span class="line">  <span class="keyword">if</span> (intersects.<span class="property">length</span>) &#123;</span><br><span class="line">    <span class="keyword">try</span> &#123;</span><br><span class="line">      <span class="keyword">if</span> (intersects[<span class="number">0</span>].<span class="property">object</span>.<span class="property">cubeType</span> === <span class="string">&#x27;coverCube&#x27;</span>) &#123;</span><br><span class="line">        intersect = intersects[<span class="number">1</span>];</span><br><span class="line">        normalize = intersects[<span class="number">0</span>].<span class="property">face</span>.<span class="property">normal</span>;</span><br><span class="line">      &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">        intersect = intersects[<span class="number">0</span>];</span><br><span class="line">        normalize = intersects[<span class="number">1</span>].<span class="property">face</span>.<span class="property">normal</span>;</span><br><span class="line">      &#125;</span><br><span class="line">    &#125; <span class="keyword">catch</span>(err) &#123;</span><br><span class="line">      <span class="comment">//nothing</span></span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">  <span class="keyword">return</span> &#123;<span class="attr">intersect</span>: intersect, <span class="attr">normalize</span>: normalize&#125;;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></p><h2 id="确定旋转方向">确定旋转方向</h2><p>下面，我们基于触点目标方块、表面法向量，再结合鼠标移动方向，计算旋转方向。具体实现原理主要包括以下几个步骤：</p><ul><li>计算鼠标的平移向量</li><li>判断平移向量与全局坐标系 6 个方向之间的夹角，选择夹角最小的方向</li><li>结合表面法向量，确定旋转方向</li></ul><p>为什么要结合表面法向量来确定旋转方向？因为同一平移向量时，表面法向量不同，则魔方的旋转方向也不同。如下所示，当鼠标平移方向接近<code>x</code> 轴方向，如果表面法向量与 <code>z</code>轴方向相同，那么魔方将环绕 <code>y</code>轴进行逆时针旋转；如果表面法向量与 <code>y</code>轴方向相同，那么魔方将环绕 <code>z</code> 轴进行顺时针旋转。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rubiks-cube-08.png?x-oss-process=image/resize,w_800" /></p><p>如下所示，为判断魔方旋转方向的代码逻辑。我们根据不同的拖拽方向分情况讨论，最终确定魔方的6 种旋转方向。 <figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 魔方转动的六个方向</span></span><br><span class="line"><span class="keyword">const</span> xLine = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">Vector3</span>( <span class="number">1</span>, <span class="number">0</span>, <span class="number">0</span> );     <span class="comment">// X轴正方向</span></span><br><span class="line"><span class="keyword">const</span> xLineAd = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">Vector3</span>( -<span class="number">1</span>, <span class="number">0</span>, <span class="number">0</span> );  <span class="comment">// X轴负方向</span></span><br><span class="line"><span class="keyword">const</span> yLine = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">Vector3</span>( <span class="number">0</span>, <span class="number">1</span>, <span class="number">0</span> );     <span class="comment">// Y轴正方向</span></span><br><span class="line"><span class="keyword">const</span> yLineAd = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">Vector3</span>( <span class="number">0</span>, -<span class="number">1</span>, <span class="number">0</span> );  <span class="comment">// Y轴负方向</span></span><br><span class="line"><span class="keyword">const</span> zLine = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">Vector3</span>( <span class="number">0</span>, <span class="number">0</span>, <span class="number">1</span> );     <span class="comment">// Z轴正方向</span></span><br><span class="line"><span class="keyword">const</span> zLineAd = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">Vector3</span>( <span class="number">0</span>, <span class="number">0</span>, -<span class="number">1</span> );  <span class="comment">// Z轴负方向</span></span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 获得旋转方向</span></span><br><span class="line"><span class="comment"> * vector3: 鼠标滑动的方向</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">getDirection</span>(<span class="params">vector3</span>) &#123;</span><br><span class="line">  <span class="keyword">var</span> direction;</span><br><span class="line">  <span class="comment">// 判断差向量和 x、y、z 轴的夹角</span></span><br><span class="line">  <span class="keyword">var</span> xAngle = vector3.<span class="title function_">angleTo</span>(xLine);</span><br><span class="line">  <span class="keyword">var</span> xAngleAd = vector3.<span class="title function_">angleTo</span>(xLineAd);</span><br><span class="line">  <span class="keyword">var</span> yAngle = vector3.<span class="title function_">angleTo</span>(yLine);</span><br><span class="line">  <span class="keyword">var</span> yAngleAd = vector3.<span class="title function_">angleTo</span>(yLineAd);</span><br><span class="line">  <span class="keyword">var</span> zAngle = vector3.<span class="title function_">angleTo</span>(zLine);</span><br><span class="line">  <span class="keyword">var</span> zAngleAd = vector3.<span class="title function_">angleTo</span>(zLineAd);</span><br><span class="line">  <span class="keyword">var</span> minAngle = <span class="title class_">Math</span>.<span class="title function_">min</span>(...[xAngle, xAngleAd, yAngle, yAngleAd, zAngle, zAngleAd]);  <span class="comment">// 最小夹角</span></span><br><span class="line">  <span class="keyword">switch</span>(minAngle)&#123;</span><br><span class="line">    <span class="keyword">case</span> <span class="attr">xAngle</span>:</span><br><span class="line">      direction = <span class="number">10</span>;  <span class="comment">// 向x轴正方向旋转90度（还要区分是绕z轴还是绕y轴）</span></span><br><span class="line">      <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(yLine)) &#123;</span><br><span class="line">        direction = direction + <span class="number">5</span>;  <span class="comment">// 绕z轴顺时针</span></span><br><span class="line">      &#125; <span class="keyword">else</span> <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(yLineAd)) &#123;</span><br><span class="line">        direction = direction + <span class="number">6</span>;  <span class="comment">// 绕z轴逆时针</span></span><br><span class="line">      &#125; <span class="keyword">else</span> <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(zLine)) &#123;</span><br><span class="line">        direction = direction + <span class="number">4</span>;  <span class="comment">// 绕y轴逆时针</span></span><br><span class="line">      &#125; <span class="keyword">else</span> <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(zLineAd)) &#123;</span><br><span class="line">        direction = direction + <span class="number">3</span>;  <span class="comment">// 绕y轴顺时针</span></span><br><span class="line">      &#125;</span><br><span class="line">      <span class="keyword">break</span>;</span><br><span class="line">    <span class="keyword">case</span> <span class="attr">xAngleAd</span>:</span><br><span class="line">      direction = <span class="number">20</span>;  <span class="comment">// 向x轴反方向旋转90度</span></span><br><span class="line">      <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(yLine)) &#123;</span><br><span class="line">        direction = direction + <span class="number">6</span>;  <span class="comment">// 绕z轴逆时针</span></span><br><span class="line">      &#125; <span class="keyword">else</span> <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(yLineAd)) &#123;</span><br><span class="line">        direction = direction + <span class="number">5</span>;  <span class="comment">// 绕z轴顺时针</span></span><br><span class="line">      &#125; <span class="keyword">else</span> <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(zLine)) &#123;</span><br><span class="line">        direction = direction + <span class="number">3</span>;  <span class="comment">// 绕y轴顺时针</span></span><br><span class="line">      &#125; <span class="keyword">else</span> <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(zLineAd)) &#123;</span><br><span class="line">        direction = direction + <span class="number">4</span>;  <span class="comment">// 绕y轴逆时针</span></span><br><span class="line">      &#125;</span><br><span class="line">      <span class="keyword">break</span>;</span><br><span class="line">    <span class="keyword">case</span> <span class="attr">yAngle</span>:</span><br><span class="line">      direction = <span class="number">30</span>;  <span class="comment">// 向y轴正方向旋转90度</span></span><br><span class="line">      <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(zLine)) &#123;</span><br><span class="line">        direction = direction + <span class="number">1</span>;  <span class="comment">// 绕x轴顺时针</span></span><br><span class="line">      &#125; <span class="keyword">else</span> <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(zLineAd)) &#123;</span><br><span class="line">        direction = direction + <span class="number">2</span>;  <span class="comment">// 绕x轴逆时针</span></span><br><span class="line">      &#125; <span class="keyword">else</span> <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(xLine)) &#123;</span><br><span class="line">        direction = direction + <span class="number">6</span>;  <span class="comment">// 绕z轴逆时针</span></span><br><span class="line">      &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">        direction = direction + <span class="number">5</span>;  <span class="comment">// 绕z轴顺时针</span></span><br><span class="line">      &#125;</span><br><span class="line">      <span class="keyword">break</span>;</span><br><span class="line">    <span class="keyword">case</span> <span class="attr">yAngleAd</span>:</span><br><span class="line">      direction = <span class="number">40</span>;  <span class="comment">// 向y轴反方向旋转90度</span></span><br><span class="line">      <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(zLine)) &#123;</span><br><span class="line">        direction = direction + <span class="number">2</span>;  <span class="comment">// 绕x轴逆时针</span></span><br><span class="line">      &#125; <span class="keyword">else</span> <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(zLineAd)) &#123;</span><br><span class="line">        direction = direction + <span class="number">1</span>;  <span class="comment">// 绕x轴顺时针</span></span><br><span class="line">      &#125; <span class="keyword">else</span> <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(xLine)) &#123;</span><br><span class="line">        direction = direction + <span class="number">5</span>;  <span class="comment">// 绕z轴顺时针</span></span><br><span class="line">      &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">        direction = direction + <span class="number">6</span>;  <span class="comment">// 绕z轴逆时针</span></span><br><span class="line">      &#125;</span><br><span class="line">      <span class="keyword">break</span>;</span><br><span class="line">    <span class="keyword">case</span> <span class="attr">zAngle</span>:</span><br><span class="line">      direction = <span class="number">50</span>;  <span class="comment">// 向z轴正方向旋转90度</span></span><br><span class="line">      <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(yLine)) &#123;</span><br><span class="line">        direction = direction + <span class="number">2</span>;  <span class="comment">// 绕x轴逆时针</span></span><br><span class="line">      &#125; <span class="keyword">else</span> <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(yLineAd)) &#123;</span><br><span class="line">        direction = direction + <span class="number">1</span>;  <span class="comment">// 绕x轴顺时针</span></span><br><span class="line">      &#125; <span class="keyword">else</span> <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(xLine)) &#123;</span><br><span class="line">        direction = direction + <span class="number">3</span>;  <span class="comment">// 绕y轴顺时针</span></span><br><span class="line">      &#125; <span class="keyword">else</span> <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(xLineAd)) &#123;</span><br><span class="line">        direction = direction + <span class="number">4</span>;  <span class="comment">// 绕y轴逆时针</span></span><br><span class="line">      &#125;</span><br><span class="line">      <span class="keyword">break</span>;</span><br><span class="line">    <span class="keyword">case</span> <span class="attr">zAngleAd</span>:</span><br><span class="line">      direction = <span class="number">60</span>;  <span class="comment">// 向z轴反方向旋转90度</span></span><br><span class="line">      <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(yLine)) &#123;</span><br><span class="line">        direction = direction + <span class="number">1</span>;  <span class="comment">// 绕x轴顺时针</span></span><br><span class="line">      &#125; <span class="keyword">else</span> <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(yLineAd)) &#123;</span><br><span class="line">        direction = direction + <span class="number">2</span>;  <span class="comment">// 绕x轴逆时针</span></span><br><span class="line">      &#125; <span class="keyword">else</span> <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(xLine)) &#123;</span><br><span class="line">        direction = direction + <span class="number">4</span>;  <span class="comment">// 绕y轴逆时针</span></span><br><span class="line">      &#125; <span class="keyword">else</span> <span class="keyword">if</span> (normalize.<span class="title function_">equals</span>(xLineAd)) &#123;</span><br><span class="line">        direction = direction + <span class="number">3</span>;  <span class="comment">// 绕y轴顺时针</span></span><br><span class="line">      &#125;</span><br><span class="line">      <span class="keyword">break</span>;</span><br><span class="line">    <span class="attr">default</span>:</span><br><span class="line">      <span class="keyword">break</span>;</span><br><span class="line">  &#125;</span><br><span class="line">  <span class="keyword">return</span> direction;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></p><h2 id="确定旋转平面">确定旋转平面</h2><p>随后，我们可以根据触点目标方块的位置，结合旋转方向，找到与它同一旋转平面的立方体。比如，对于绕<code>x</code> 轴旋转时，我们只需要找到所有与触点目标方块的<code>x</code> 坐标相同的立方体即可。相关实现如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rubiks-cube-11.png?x-oss-process=image/resize,w_800" /></p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 根据立方体和旋转方向，找到同一平面上的所有立方体</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">getPlaneCubes</span>(<span class="params">cube, direction</span>) &#123;</span><br><span class="line">  <span class="keyword">let</span> results = [];</span><br><span class="line">  <span class="keyword">let</span> orientation = direction % <span class="number">10</span>;</span><br><span class="line">  <span class="keyword">let</span> radians = (orientation % <span class="number">2</span> == <span class="number">1</span>) ? <span class="number">90</span> : -<span class="number">90</span>;</span><br><span class="line">  <span class="keyword">switch</span> (orientation) &#123;</span><br><span class="line">    <span class="keyword">case</span> <span class="number">1</span>:</span><br><span class="line">    <span class="keyword">case</span> <span class="number">2</span>:</span><br><span class="line">      <span class="comment">// 绕x轴</span></span><br><span class="line">      <span class="keyword">for</span> (<span class="keyword">let</span> i = <span class="number">0</span>; i &lt; cubes.<span class="property">length</span>; i++) &#123;</span><br><span class="line">        <span class="keyword">let</span> curr = cubes[i];</span><br><span class="line">        <span class="variable language_">console</span>.<span class="title function_">log</span>(curr.<span class="property">position</span>);</span><br><span class="line">        <span class="keyword">if</span> (<span class="title class_">Math</span>.<span class="title function_">abs</span>(curr.<span class="property">position</span>.<span class="property">x</span> - cube.<span class="property">position</span>.<span class="property">x</span>) &lt; <span class="number">0.2</span>) &#123;</span><br><span class="line">          results.<span class="title function_">push</span>(curr);</span><br><span class="line">        &#125;</span><br><span class="line">      &#125;</span><br><span class="line">      <span class="keyword">break</span>;</span><br><span class="line">    <span class="keyword">case</span> <span class="number">3</span>:</span><br><span class="line">    <span class="keyword">case</span> <span class="number">4</span>:</span><br><span class="line">      <span class="comment">// 绕y轴</span></span><br><span class="line">      <span class="keyword">for</span> (<span class="keyword">let</span> i = <span class="number">0</span>; i &lt; cubes.<span class="property">length</span>; i++) &#123;</span><br><span class="line">        <span class="keyword">let</span> curr = cubes[i];</span><br><span class="line">        <span class="variable language_">console</span>.<span class="title function_">log</span>(curr.<span class="property">position</span>);</span><br><span class="line">        <span class="keyword">if</span> (<span class="title class_">Math</span>.<span class="title function_">abs</span>(curr.<span class="property">position</span>.<span class="property">y</span> - cube.<span class="property">position</span>.<span class="property">y</span>) &lt; <span class="number">0.2</span>) &#123;</span><br><span class="line">          results.<span class="title function_">push</span>(curr);</span><br><span class="line">        &#125;</span><br><span class="line">      &#125;</span><br><span class="line">      <span class="keyword">break</span>;</span><br><span class="line">    <span class="keyword">case</span> <span class="number">5</span>:</span><br><span class="line">    <span class="keyword">case</span> <span class="number">6</span>:</span><br><span class="line">      <span class="comment">// 绕z轴</span></span><br><span class="line">      <span class="keyword">for</span> (<span class="keyword">let</span> i = <span class="number">0</span>; i &lt; cubes.<span class="property">length</span>; i++) &#123;</span><br><span class="line">        <span class="keyword">let</span> curr = cubes[i];</span><br><span class="line">        <span class="variable language_">console</span>.<span class="title function_">log</span>(curr.<span class="property">position</span>);</span><br><span class="line">        <span class="keyword">if</span> (<span class="title class_">Math</span>.<span class="title function_">abs</span>(curr.<span class="property">position</span>.<span class="property">z</span> - cube.<span class="property">position</span>.<span class="property">z</span>) &lt; <span class="number">0.2</span>) &#123;</span><br><span class="line">          results.<span class="title function_">push</span>(curr);</span><br><span class="line">        &#125;</span><br><span class="line">      &#125;</span><br><span class="line">      <span class="keyword">break</span>;</span><br><span class="line">  &#125;</span><br><span class="line">  <span class="keyword">return</span> results;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="实现旋转动画">实现旋转动画</h2><p>最后，我们需要实现旋转动画。对此，我们首先定义动画时长，根据当前时长与动画时长的比例，计算当前旋转角度的比例，并更新位置，从而实现旋转效果。关于旋转变换，我们在<ahref="https://chuquan.me/2024/01/18/foundation-of-computer-graphic-02/#%E6%97%8B%E8%BD%AC%E5%8F%98%E6%8D%A2">《计算机图形学基础（2）——变换》</a>一文中也介绍过。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rubiks-cube-09.png?x-oss-process=image/resize,w_800" /></p><p>我们以 2D平面中的物体旋转来推导旋转矩阵。上图所示，我们将左边的图片进行旋转得到右边的图片，那么我们必须求解如下所示的矩阵运算公式，其中<span class="math inline">\(A\)</span>、<spanclass="math inline">\(B\)</span>、<spanclass="math inline">\(C\)</span>、<span class="math inline">\(D\)</span>为待求解的变量。</p><span class="math display">\[\begin{aligned}\left(\begin{matrix}x&#39; \\y&#39;\end{matrix}\right)=\left(\begin{matrix}A &amp; B \\C &amp; D \\\end{matrix}\right)\left(\begin{matrix}x \\y\end{matrix}\right)\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rubiks-cube-10.png?x-oss-process=image/resize,w_800" /></p><p>为了求解 <span class="math inline">\(A\)</span>、<spanclass="math inline">\(B\)</span>、<spanclass="math inline">\(C\)</span>、<span class="math inline">\(D\)</span>四个变量，我们将以 <span class="math inline">\((0, 1)\)</span> 和 <spanclass="math inline">\((1, 0)\)</span> 两个点的旋转为例求解方程。</p><p>对于 <span class="math inline">\((0, 1)\)</span>点的旋转，我们可以得到如下方程：</p><span class="math display">\[\begin{aligned}\left(\begin{matrix}-sin \theta\\cos \theta\end{matrix}\right)=\left(\begin{matrix}A &amp; B \\C &amp; D \\\end{matrix}\right)\left(\begin{matrix}0 \\1\end{matrix}\right)\\-sin \theta = A * 0 + B * 1 = B\\cos \theta = C * 0 + D * 1 = D\end{aligned}\]</span><p>对于 <span class="math inline">\((1, 0)\)</span>点的旋转，我们可以得到如下方程：</p><span class="math display">\[\begin{aligned}\left(\begin{matrix}cos \theta\\sin \theta\end{matrix}\right)=\left(\begin{matrix}A &amp; B \\C &amp; D \\\end{matrix}\right)\left(\begin{matrix}1 \\0\end{matrix}\right)\\cos \theta = A * 1 + B * 0 = A\\sin \theta = C * 1 + D * 0 = C\end{aligned}\]</span><p>至此 <span class="math inline">\(A\)</span>、<spanclass="math inline">\(B\)</span>、<spanclass="math inline">\(C\)</span>、<span class="math inline">\(D\)</span>四个变量均已求解，由此得到旋转矩阵如下：</p><span class="math display">\[\begin{aligned}\left(\begin{matrix}x&#39;\\y&#39;\end{matrix}\right)=\left(\begin{matrix}cos\theta &amp; -sin\theta \\sin\theta &amp; cos\theta \\\end{matrix}\right)\left(\begin{matrix}1 \\0\end{matrix}\right)\end{aligned}\]</span><p>进而得到 <span class="math inline">\(x&#39;\)</span> 和 <spanclass="math inline">\(y&#39;\)</span> 的计算公式如下：</p><span class="math display">\[\begin{aligned}x&#39; = cos\theta x - sin\theta y\\y&#39; = sin\theta x + cos\theta y\end{aligned}\]</span><p>由于魔方的旋转都是沿着一个轴进行旋转，所以我们可以将它看成三种情况的2D 平面旋转，由此得到如下 3 个旋转方法。</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">rotateAroundWorldX</span>(<span class="params">cube, rad</span>)&#123;</span><br><span class="line">    <span class="keyword">var</span> y0 = cube.<span class="property">position</span>.<span class="property">y</span>;</span><br><span class="line">    <span class="keyword">var</span> z0 = cube.<span class="property">position</span>.<span class="property">z</span>;</span><br><span class="line">    <span class="keyword">var</span> q = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">Quaternion</span>(); </span><br><span class="line">    q.<span class="title function_">setFromAxisAngle</span>(<span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">Vector3</span>( <span class="number">1</span>, <span class="number">0</span>, <span class="number">0</span> ), rad);</span><br><span class="line">    cube.<span class="property">quaternion</span>.<span class="title function_">premultiply</span>(q);</span><br><span class="line">    cube.<span class="property">position</span>.<span class="property">y</span> = <span class="title class_">Math</span>.<span class="title function_">cos</span>(rad) * y0 - <span class="title class_">Math</span>.<span class="title function_">sin</span>(rad) * z0;</span><br><span class="line">    cube.<span class="property">position</span>.<span class="property">z</span> = <span class="title class_">Math</span>.<span class="title function_">cos</span>(rad) * z0 + <span class="title class_">Math</span>.<span class="title function_">sin</span>(rad) * y0;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">function</span> <span class="title function_">rotateAroundWorldY</span>(<span class="params">cube, rad</span>)&#123;</span><br><span class="line">    <span class="keyword">var</span> x0 = cube.<span class="property">position</span>.<span class="property">x</span>;</span><br><span class="line">    <span class="keyword">var</span> z0 = cube.<span class="property">position</span>.<span class="property">z</span>;</span><br><span class="line">    <span class="keyword">var</span> q = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">Quaternion</span>(); </span><br><span class="line">    q.<span class="title function_">setFromAxisAngle</span>(<span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">Vector3</span>( <span class="number">0</span>, <span class="number">1</span>, <span class="number">0</span> ), rad);</span><br><span class="line">    cube.<span class="property">quaternion</span>.<span class="title function_">premultiply</span>( q );</span><br><span class="line">    cube.<span class="property">position</span>.<span class="property">x</span> = <span class="title class_">Math</span>.<span class="title function_">cos</span>(rad) * x0 + <span class="title class_">Math</span>.<span class="title function_">sin</span>(rad) * z0;</span><br><span class="line">    cube.<span class="property">position</span>.<span class="property">z</span> = <span class="title class_">Math</span>.<span class="title function_">cos</span>(rad) * z0 - <span class="title class_">Math</span>.<span class="title function_">sin</span>(rad) * x0;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">function</span> <span class="title function_">rotateAroundWorldZ</span>(<span class="params">cube, rad</span>)&#123;</span><br><span class="line">    <span class="keyword">var</span> x0 = cube.<span class="property">position</span>.<span class="property">x</span>;</span><br><span class="line">    <span class="keyword">var</span> y0 = cube.<span class="property">position</span>.<span class="property">y</span>;</span><br><span class="line">    <span class="keyword">var</span> q = <span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">Quaternion</span>(); </span><br><span class="line">    q.<span class="title function_">setFromAxisAngle</span>(<span class="keyword">new</span> <span class="variable constant_">THREE</span>.<span class="title class_">Vector3</span>( <span class="number">0</span>, <span class="number">0</span>, <span class="number">1</span> ), rad);</span><br><span class="line">    cube.<span class="property">quaternion</span>.<span class="title function_">premultiply</span>( q );</span><br><span class="line">    cube.<span class="property">position</span>.<span class="property">x</span> = <span class="title class_">Math</span>.<span class="title function_">cos</span>(rad) * x0 - <span class="title class_">Math</span>.<span class="title function_">sin</span>(rad) * y0;</span><br><span class="line">    cube.<span class="property">position</span>.<span class="property">y</span> = <span class="title class_">Math</span>.<span class="title function_">cos</span>(rad) * y0 + <span class="title class_">Math</span>.<span class="title function_">sin</span>(rad) * x0;</span><br><span class="line">  &#125;</span><br></pre></td></tr></table></figure><p>由于这几个方法仅仅旋转物体、更新坐标，实际上我们需要在一段时间内连续进行调用，从而实现一个完整的旋转动画，具体的调用实现如下所示。</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">rotateAnimation</span>(<span class="params">cubes, direction, currentstamp, startstamp, laststamp</span>) &#123;</span><br><span class="line">    <span class="keyword">if</span>(startstamp === <span class="number">0</span>)&#123;</span><br><span class="line">        startstamp = currentstamp;</span><br><span class="line">        laststamp = currentstamp;</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">if</span>(currentstamp - startstamp &gt;= rotateDuration)&#123;</span><br><span class="line">      currentstamp = startstamp + rotateDuration;</span><br><span class="line">      isRotating = <span class="literal">false</span>;</span><br><span class="line">      startPoint = <span class="literal">null</span>;</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">let</span> orientation = direction % <span class="number">10</span>;</span><br><span class="line">    <span class="keyword">let</span> radians = (orientation % <span class="number">2</span> == <span class="number">1</span>) ? -<span class="number">90</span> : <span class="number">90</span>;</span><br><span class="line">    <span class="keyword">switch</span> (orientation) &#123;</span><br><span class="line">      <span class="keyword">case</span> <span class="number">1</span>:</span><br><span class="line">      <span class="keyword">case</span> <span class="number">2</span>:</span><br><span class="line">        <span class="keyword">for</span> (<span class="keyword">let</span> i = <span class="number">0</span>; i &lt; cubes.<span class="property">length</span>; i++) &#123;</span><br><span class="line">          <span class="title function_">rotateAroundWorldX</span>(cubes[i], radians * <span class="title class_">Math</span>.<span class="property">PI</span> / <span class="number">180</span> * (currentstamp - laststamp) / rotateDuration);</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">break</span>;</span><br><span class="line">      <span class="keyword">case</span> <span class="number">3</span>:</span><br><span class="line">      <span class="keyword">case</span> <span class="number">4</span>:</span><br><span class="line">        <span class="keyword">for</span> (<span class="keyword">let</span> i = <span class="number">0</span>; i &lt; cubes.<span class="property">length</span>; i++) &#123;</span><br><span class="line">          <span class="title function_">rotateAroundWorldY</span>(cubes[i], radians * <span class="title class_">Math</span>.<span class="property">PI</span> / <span class="number">180</span> * (currentstamp - laststamp) / rotateDuration);</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">break</span>;</span><br><span class="line">      <span class="keyword">case</span> <span class="number">5</span>:</span><br><span class="line">      <span class="keyword">case</span> <span class="number">6</span>:</span><br><span class="line">        <span class="keyword">for</span> (<span class="keyword">let</span> i = <span class="number">0</span>; i &lt; cubes.<span class="property">length</span>; i++) &#123;</span><br><span class="line">          <span class="title function_">rotateAroundWorldZ</span>(cubes[i], radians * <span class="title class_">Math</span>.<span class="property">PI</span> / <span class="number">180</span> * (currentstamp - laststamp) / rotateDuration);</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">break</span>;</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">if</span>(currentstamp - startstamp &lt; rotateDuration)&#123;</span><br><span class="line">      <span class="title function_">requestAnimationFrame</span>(<span class="function">(<span class="params">timestamp</span>) =&gt;</span> &#123;</span><br><span class="line">        <span class="title function_">rotateAnimation</span>(cubes, direction, timestamp, startstamp, currentstamp);</span><br><span class="line">      &#125;);</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br></pre></td></tr></table></figure><p>至此，我们实现了通过鼠标控制魔方的旋转，完整的代码可以参考 <ahref="https://github.com/baochuquan/rubiks-cube/blob/main/src/components/RubiksCube02.vue">RubiksCube02.vue</a>文件。</p><h1 id="总结">总结</h1><p>本文我们基于 Threejs 实现了一个 3D魔方，并支持了通过鼠标控制魔方旋转的功能。后续，我们将进一步介绍如何实现魔方的自动还原算法。</p><h1 id="参考">参考</h1><ol type="1"><li><ahref="https://mofang.1or9.com/mofangdingyi.shtml">魔方基本定义</a></li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;&lt;img
src=&quot;https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rubiks-cube-12.png?x-oss-process=image/resize,w_800&quot; /&gt;&lt;/p&gt;</summary>
    
    
    
    <category term="Threejs" scheme="http://chuquan.me/categories/Threejs/"/>
    
    
    <category term="Threejs" scheme="http://chuquan.me/tags/Threejs/"/>
    
    <category term="vue" scheme="http://chuquan.me/tags/vue/"/>
    
  </entry>
  
  <entry>
    <title>欢迎关注我的 Twitter 账号！</title>
    <link href="http://chuquan.me/2024/05/16/my-twitter-account/"/>
    <id>http://chuquan.me/2024/05/16/my-twitter-account/</id>
    <published>2024-05-16T08:43:14.000Z</published>
    <updated>2024-07-19T00:19:00.096Z</updated>
    
    <content type="html"><![CDATA[<p>最近在思考一个问题：如何打造个人品牌？思来想去，我觉得要做到以下几点：</p><ul><li>确定目标受众</li><li>培养专业能力</li><li>维持社交媒体</li><li>建立人脉关系</li></ul><span id="more"></span><p>一直以来，我并没有主动意识到要去打造个人品牌，也白白浪费了写博客的这几年。另一方面，我使用的社交媒体较少，仅限于微信、微博。而其中的朋友和粉丝基本上都是同事、同学、亲戚、朋友。在这样的熟人关系中经常发表一些日常的想法和看法，显然不太合适。从产品的角度解释就是目标人群定位不准确，所以很少更新社交媒体。</p><p>后续，我准备长期维持一个 Twitter账号，发表一些日常的想法和看法。欢迎各位朋友关注 <ahref="https://twitter.com/Baochuquan">BaoChuquan</a>，你们的支持是我创作的动力！</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;最近在思考一个问题：如何打造个人品牌？思来想去，我觉得要做到以下几点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;确定目标受众&lt;/li&gt;
&lt;li&gt;培养专业能力&lt;/li&gt;
&lt;li&gt;维持社交媒体&lt;/li&gt;
&lt;li&gt;建立人脉关系&lt;/li&gt;
&lt;/ul&gt;</summary>
    
    
    
    <category term="生活" scheme="http://chuquan.me/categories/%E7%94%9F%E6%B4%BB/"/>
    
    
    <category term="Twitter" scheme="http://chuquan.me/tags/Twitter/"/>
    
  </entry>
  
  <entry>
    <title>如何找到最合适的代码审查者？</title>
    <link href="http://chuquan.me/2024/05/11/git-reviewer/"/>
    <id>http://chuquan.me/2024/05/11/git-reviewer/</id>
    <published>2024-05-11T14:02:51.000Z</published>
    <updated>2024-05-11T14:19:09.384Z</updated>
    
    <content type="html"><![CDATA[<p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/git-reviewer-01.png?x-oss-process=image/resize,w_800" /></p><span id="more"></span><p>我的日常工作中有相当一部分时间会用于审查团队成员所提交的代码，即 CodeReview。然而，由我来审查所有代码并不合适，毕竟很多时候我并不了解代码改动的上下文。如果让我重新熟悉别人写的原始逻辑，然后再审查代码改动，很显然是一件费时费力的事情。对此，最好的办法是，针对一次代码提交，合理地找出其最合适的代码审查者。</p><p>为了解决这个问题，我开发了一个 Git 插件——<ahref="https://github.com/baochuquan/git-reviewer">Git Reviewer</a>。</p><h1 id="功能">功能</h1><h2 id="核心功能">核心功能</h2><p>我们知道 Git使用新增和删除两种操作来表示代码改动。事实上，我们还可以从新增和删除两种操作的排列关系中得出第三种操作——编辑。当删除操作和新增操作相互紧邻，那么我们可以将其归为编辑操作。如下所示的代码差异中包含了新增、编辑、删除三种操作。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/git-reviewer-03.png?x-oss-process=image/resize,w_800" /></p><p>Git Reviewer 正是基于这三种操作进行了分析：</p><ul><li><p>对于删除类型，Git Reviewer认为删除行的原始作者应该作为每一行的审查者。</p></li><li><p>对于编辑类型，Git Reviewer认为此部分中的新增内容应该全部由紧邻的最后删除行的原始作者作为审查者。注意，为什么是最后删除行？因为Git 所采用的 Myers差分算法默认差分内容优先展示删除操作，其次才是新增操作。因此，从最后的删除行开始，展示的是新增的内容。</p></li><li><p>对于新增类型，Git Reviewer 的策略是基于<code>.gitreviewer.yml</code>配置文件进行分析。<code>.gitreviewer.yml</code> 文件定义了项目所有者<code>project owner</code>、目录所有者<code>folder owner</code>、文件所有者 <code>file owner</code>。此时，GitReviewer 会对新增行的文件与 <code>.gitreviewer.yml</code>所定义的内容进行匹配。</p><ul><li>如果该文件匹配到了文件所有者，那么相关新增类型优先由文件所有者审查。</li><li>如果该文件匹配到了目录所有者，那么相关新增类型其次由目录所有者审查。</li><li>如果前两者均没有匹配该文件，那么将由项目所有者来进行审查。</li></ul></li></ul><p>基于对上述三种操作类型进行分析，Git Reviewer最终将生成一个分析表格，其中罗列了审查者、文件数量、文件占比、代码行数量、代码行占比等信息。GitReviewer 建议以代码行占比为依据，对审查者进行排序。</p><p>如下所示，为核心功能的分析结果示例。</p><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">+------------------------------------------------------------------------+</span><br><span class="line">|                 Suggested reviewers <span class="keyword">for</span> code changes                   |</span><br><span class="line">+--------------------+------------+------------+------------+------------+</span><br><span class="line">| Suggested Reviewer | File Count | File Ratio | Line Count | Line Ratio |</span><br><span class="line">+--------------------+------------+------------+------------+------------+</span><br><span class="line">| developerA         | 5          | 50.0%      | 1000       | 50.0%      |</span><br><span class="line">+--------------------+------------+------------+------------+------------+</span><br><span class="line">| developerB         | 3          | 30.0%      | 500        | 25.0%      |</span><br><span class="line">+--------------------+------------+------------+------------+------------+</span><br><span class="line">| developerC         | 2          | 20.0%      | 500        | 25.0%      |</span><br><span class="line">+--------------------+------------+------------+------------+------------+</span><br></pre></td></tr></table></figure><h2 id="附加功能">附加功能</h2><p>Git Reviewer还提供了分析代码改动的所涉及的作者分布的功能。此功能相对简单，其分析了所有删除行的原始作者和新增行的现有作者，并同样以表格的形式呈现，罗列作者、文件数量、文件占比、代码行数量、代码行占比等信息，以供用户进行评估和参考。</p><p>如下所示，为附加功能的分析结果示例。</p><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">+--------------------------------------------------------------------+</span><br><span class="line">|             Relevant authors involved <span class="keyword">in</span> code changes              |</span><br><span class="line">+----------------+------------+------------+------------+------------+</span><br><span class="line">| Related Author | File Count | File Ratio | Line Count | Line Ratio |</span><br><span class="line">+----------------+------------+------------+------------+------------+</span><br><span class="line">| developerA     | 5          | 50.0%      | 2000       | 66.6%      |</span><br><span class="line">+----------------+------------+------------+------------+------------+</span><br><span class="line">| developerB     | 3          | 30.0%      | 500        | 16.7%      |</span><br><span class="line">+----------------+------------+------------+------------+------------+</span><br><span class="line">| developerC     | 2          | 30.0%      | 500        | 16.7%      |</span><br><span class="line">+----------------+------------+------------+------------+------------+</span><br></pre></td></tr></table></figure><h1 id="安装">安装</h1><p>Git Reviewer 支持通过 Homebrew进行安装，命令如下所示。当然，这也是建议的安装方式。</p><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">$ brew install baochuquan/tap/git-reviewer</span><br></pre></td></tr></table></figure><p>或者，也可以通过 Ruby Gem 进行安装，命令如下所示。</p><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">$ gem install git-reviewer</span><br></pre></td></tr></table></figure><h1 id="使用">使用</h1><p>对于任意 Git 项目，在使用 Git Reviewer之前应该先在根目录下执行初始化命令，如下所示。</p><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">$ git reviewer --init</span><br></pre></td></tr></table></figure><p>该命令会自动创建一个 <code>.gitreviewer.yml</code> 文件，内部定义了<code>project_owner</code>，<code>folder_owner</code>，<code>file_owner</code>等字段，其中后两者是数组类型，我们可以定义多个<code>path</code>、<code>owner</code>字段，从而对项目进行更精准的划分。</p><p>此外，<code>.gitreviewer.yml</code> 文件还包含<code>ignore_folders</code>、<code>ignore_files</code>字段，它们可以定义一系列目录或文件，以避免加入分析，从而影响分析结果。</p><p>如下所示，是一个 <code>.gitreviewer.yml</code>的示例，我们可以编辑相关字段，从而实现更精准的分析。</p><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line">---</span><br><span class="line">project_owner: admin,</span><br><span class="line">folder_owner:</span><br><span class="line">- owner: developerA,</span><br><span class="line">  path: /path/to/folderA</span><br><span class="line">- owner: developerB</span><br><span class="line">  path: /path/to/folderB</span><br><span class="line"> </span><br><span class="line">file_owner:</span><br><span class="line">- owner: developerC</span><br><span class="line">  path: /path/to/fileC</span><br><span class="line">- owner: developerD</span><br><span class="line">  path: /path/to/fileD</span><br><span class="line"> </span><br><span class="line">ignore_files:</span><br><span class="line">- path/to/file1</span><br><span class="line">- path/to/file2</span><br><span class="line"> </span><br><span class="line">ignore_review_folders:</span><br><span class="line">- path/to/folder1</span><br><span class="line">- path/to/folder2</span><br></pre></td></tr></table></figure><h2 id="分析">分析</h2><p>Git Reviewer 基于两个 Git 分支进行分析，分别是源分支和目标分支。</p><p>源分支，即代码修改所在的分支。默认情况下，Git Reviewer自动获取当前所在分支作为源分支。当然，也可以使用选项来指定源分支<code>--source=&lt;source-branch&gt;</code>。除了分支名，Git Reviewer也支持 Commit ID。</p><p>目标分支，即准备合入的目标分支。对此，Git Reviewer 提供了相关选项<code>--target=&lt;target-branch&gt;</code>。</p><p>如下所示是使用 Git Reviewer 进行分析的命令示例。</p><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">$ git reviewer --target=main</span><br></pre></td></tr></table></figure><p>默认情况下，Git Reviewer会同时展示核心功能和附加功能的分析结果。如果我们只希望查看核心功能的结果，可以指定选项<code>--reviewer</code>；如果我们只希望查看附加功能的结果，可以指定选项<code>--author</code>。</p><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">$ git reviewer --target=main --reviewer</span><br><span class="line"></span><br><span class="line">$ git reviewer --target=main --author</span><br></pre></td></tr></table></figure><p>为了查看更多分析信息，我们可以加上 --verbose 选项。</p><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">$ git reviewer --target=main --verbose</span><br></pre></td></tr></table></figure><h1 id="后续">后续</h1><p>目前，Git Reviewer仅仅经历了我自己的自测，未来我会在项目上进行实践。如果遇到问题也会逐步进行修复。当然，在使用过程中还会遇到不足之处或者新的痛点，因此会逐步进行完善和迭代。</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;&lt;img
src=&quot;https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/git-reviewer-01.png?x-oss-process=image/resize,w_800&quot; /&gt;&lt;/p&gt;</summary>
    
    
    
    <category term="作品集" scheme="http://chuquan.me/categories/%E4%BD%9C%E5%93%81%E9%9B%86/"/>
    
    
    <category term="git-reviewer" scheme="http://chuquan.me/tags/git-reviewer/"/>
    
    <category term="Myers" scheme="http://chuquan.me/tags/Myers/"/>
    
  </entry>
  
  <entry>
    <title>计算机图形学基础（8）——光线追踪</title>
    <link href="http://chuquan.me/2024/04/27/foundation-of-computer-graphic-08/"/>
    <id>http://chuquan.me/2024/04/27/foundation-of-computer-graphic-08/</id>
    <published>2024-04-27T03:13:09.000Z</published>
    <updated>2024-09-22T13:26:51.280Z</updated>
    
    <content type="html"><![CDATA[<p>之前我们介绍了在游戏领域中广泛使用的实时渲染技术——光栅化，本文我们来介绍一下在特效领域中广泛使用的离线渲染技术——光线追踪。</p><span id="more"></span><h1 id="概述">概述</h1><p>那么有了光栅化渲染技术，为什么还要用光线追踪呢？根本原因在于光栅化的着色局部性，使得它无法解决很多全局效果，比如：</p><ul><li><strong>软阴影</strong>（Soft shadows）</li><li><strong>光泽反射</strong>（Glossy reflection）</li><li><strong>间接光照</strong>（Indirect illumination）</li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-01.png?x-oss-process=image/resize,w_800" /></p><p>软阴影的产生是由于光源的大小和距离。一个面积较大的光源照射一个物体，可以从多个角度投射光线到物体上。当光线从不同角度照射时，阴影边缘的重叠区域会因光线部分遮挡而出现阴影的渐变效果。</p><p>光泽反射是一种既不完全镜面反射也不完全漫反射的光反射现象。光泽度高、粗糙度低的表面产生的反射相对清晰，接近镜面反射；而光泽度低、粗糙度高的表面，反射图像就会更加模糊，趋向漫反射。</p><p>间接光照是一个场景中光线经过一次或多次反射后照射到物体表面的光照效果。在真实世界中，一个房间只通过一扇窗投过光照，但是房间里没有一个地方是完全黑色的，这就是光的多次反射造成的。</p><h1 id="底层依据">底层依据</h1><p>光线追踪依赖以下几个基本的底层依据，分别是：</p><ul><li><strong>光线沿直线传播</strong>：在微观角度，光是沿着波形传播；在宏观角度，光是沿着直线传播。</li><li><strong>光线相互不碰撞</strong>：当光线的传播路径发生交叠时，我们认为光线的传播互不干扰。</li><li><strong>光线具有可逆性</strong>：真实世界中，视觉成像是因为物体发射或反射光线，进入视网膜；在图形学中，屏幕成像是因为从相机向像素建立了反向的光线传播路径，采集路径上的所有着色信息。</li></ul><h1 id="基本原理">基本原理</h1><p>光线追踪的基本原理非常简单，其采用了<strong>针孔相机模型</strong>（Pinhole Camera Model），分为<strong>视线生成</strong> 和 <strong>像素着色</strong> 两个阶段。</p><p>对于视线生成，以相机为起点，以像素为锚点，利用光的可逆性，建立一条视觉射线，如下所示。此时，我们需要找到视线与空间中的物体所产生的最近的交点。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-02.png?x-oss-process=image/resize,w_800" /></p><p>对于像素着色，当我们找到视线与物体之间的最近交点时，随即建立交点与光源的连线，判断交点是否在阴影之中，并根据结果来计算着色，写回像素结果。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-03.png?x-oss-process=image/resize,w_800" /></p><h1 id="光线追踪">光线追踪</h1><p>上述基本原理只考虑了光源的直接影响，没有考虑间接影响。在实际情况中，光线会经过空间中物体的多次弹射，汇聚至交点上，从而对着色产生间接影响。</p><p>对此，<strong>经典光线追踪</strong>，也称<strong>惠特式光线追踪</strong>（Whitted-Style Ray Tracing）或<strong>递归光线追踪</strong>（Recursive RayTracing），引入了间接光照的处理。</p><p>在视线生成之后，我们找到最近的交点，然后根据光线的可逆性，找到反射光线（ReflectedRay）与折射光线（Refracted Ray）所产生的交点，依次类推，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-08.png?x-oss-process=image/resize,w_800" /></p><p>之后，我们将所有交点分别与光源进行连线，判断这些交点是否在阴影之中，并根据结果计算着色，写回像素结果。当然，在反射和折射过程中会存在能量折损，因此，在计算着色时也会考虑光线折损的影响。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-09.png?x-oss-process=image/resize,w_800" /></p><h1 id="技术细节">技术细节</h1><p>光线追踪的整体原理非常简单，下面，我们来考虑其中所涉及到的一些技术细节。</p><h2 id="交点判定原理">交点判定原理</h2><p>光线追踪中最重要的技术是交点判定，其中包含光线表示和几何表示两部分。</p><p>对于光线表示，我们只需要使用原点、方向向量即可进行表示，如下所示。</p><span class="math display">\[\begin{aligned}\vec{r}(t) = \vec{o} + t\vec{d}\end{aligned}\]</span><p>对于几何表示，在 <ahref="http://chuquan.me/2024/04/10/foundation-of-computer-graphic-06/">《计算机图形学基础（6）——几何》</a>中，我们提到隐式几何表示和显式几何表示两种。</p><h3 id="隐式几何的交点">隐式几何的交点</h3><p>隐式几何使用数学关系式表示，因此我们可以结合光线表示来求解方程组，即可计算得到交点，如下所示。其中，<spanclass="math inline">\(f\)</span>为隐式几何的数学关系式，将光线表示作为参数代入，求解 <spanclass="math inline">\(t\)</span> 即可得到交点。</p><span class="math display">\[\begin{aligned}f(\vec{o} + t\vec{d}) = 0\end{aligned}\]</span><h3 id="显式几何的交点">显式几何的交点</h3><p>显式几何直接或间接（通过参数映射的方式）定义点、线、面等元素集合，基本上最终都会转换成多边形网格来表示。这里，我们介绍平面和三角形两种情况下的交点判定技术。</p><h4 id="平面交点判定">平面交点判定</h4><p>对于任意一个平面，我们可以使用一个向量和一个点来定义。其中，向量是法向量，平面上任意一个点与<span class="math inline">\(\vec{p&#39;}\)</span>所构成的向量都与法向量垂直，两者的点积为0，因此得到如下所示的平面定义。</p><span class="math display">\[\begin{aligned}(\vec{p} - \vec{p&#39;}) \cdot \vec{N} = 0\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-10.png?x-oss-process=image/resize,w_800" /></p><p>此时，我们再结合平面定义和光线表示得到如下所示的方程组，进而解得<span class="math inline">\(t\)</span> 的值，即交点。</p><span class="math display">\[\begin{aligned}(\vec{o} + t \vec{d} - \vec{p&#39;}) \cdot \vec{N} = 0\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-11.png?x-oss-process=image/resize,w_800" /></p><h4 id="三角形交点判定">三角形交点判定</h4><p>对于三角形交点，我们可以通过 Moller Trumbore Algorithm快速求解。算法利用重心坐标定义三角形内的一个点，以此得到如下关系式。</p><span class="math display">\[\begin{aligned}\vec{o} + t \vec{d} = (1 - b_1 - b_2)\vec{P_0} + b_1\vec{P_1} +b_2\vec{P_2}\end{aligned}\]</span><p>根据重心坐标的特性，当 <span class="math inline">\(1- b_1 - b_2 =0\)</span> 时，点在三角形内，因此关系式中存在三个变量 <spanclass="math inline">\(t\)</span>、<spanclass="math inline">\(b_1\)</span>、<spanclass="math inline">\(b_2\)</span>。由于关系式中的向量都是三维空间中的向量，因此每个向量都由三个坐标值构成，可以进一步得到三个线性方程组，从而求解各个变量。</p><h2 id="交点判定加速">交点判定加速</h2><p>在实际应用中，三维空间可能包含非常多的物体，每个物体可能包含非常多的三角形。假如，我们要判断光线与一个物体的交点，那么需要遍历物体的每一个三角形，如下所示。很显然，这是一个非常耗时的操作，尤其是场景中物体非常多的情况下。对此，图形学中也有一些针对交点判定加速的优化方法。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-12.png?x-oss-process=image/resize,w_800" /></p><h3 id="包围盒">包围盒</h3><p><strong>包围盒</strong>（BoundingBox）的原理是使用一个简单的几何体包围一个复杂的物体，如果光线与包围盒没有交点，那么必然与内部物体没有交点，从而达到交点判定加速的目的。</p><h4 id="轴对齐包围盒">轴对齐包围盒</h4><p>包围盒通常是长方体。对于长方体，我们将它理解成<strong>三对不同的平行面形成的交集</strong>，在实际应用中，我们会使用一种特殊的长方体，即三对面各自与坐标系的轴对齐，因此称为<strong>轴对齐包围盒</strong>（Axis-Aligned Bounding Box，AABB）。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-13.png?x-oss-process=image/resize,w_800" /></p><h4 id="交点判定">交点判定</h4><p>很显然，接下来的问题就是如何判断光线是否与轴对齐包围盒相交。这里，我们首先考虑2D 情况下的交点判定，然后进一步延伸至 3D 情况下的交点判定。</p><p>对于 2D 的情况，如下所示，分为三个步骤：</p><ul><li>考虑光线与 <span class="math inline">\(x_0\)</span>、<spanclass="math inline">\(x_1\)</span>两个面的交点，根据方程组可以计算出一组 <spanclass="math inline">\(t_{min}\)</span> 和 <spanclass="math inline">\(t_{max}\)</span>。</li><li>考虑光线与 <span class="math inline">\(y_0\)</span>、<spanclass="math inline">\(y_1\)</span>两个面的交点，根据方程组又可以计算出一组 <spanclass="math inline">\(t_{min}\)</span> 和 <spanclass="math inline">\(t_{max}\)</span>。</li><li>对于两组 <span class="math inline">\(t_{min}\)</span> 和 <spanclass="math inline">\(t_{max}\)</span>，我们必须保证 <spanclass="math inline">\(t_{min}\)</span> 大于0，否则表示交点在光源的背后，没有实际意义。两组 <spanclass="math inline">\(t_{min}\)</span> 和 <spanclass="math inline">\(t_{max}\)</span>各自构成一个线段，我们只需要计算两者的交集，即可得到光线在长方形内的传播路径，而传播路径的两个端点正是光线与长方形的两个交点。</li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-14.png?x-oss-process=image/resize,w_800" /></p><p>对于 3D 的情况，我们只需要额外延伸一步即可。</p><ul><li>考虑光线与 <span class="math inline">\(z_0\)</span>、<spanclass="math inline">\(z_1\)</span>两个面的交点，根据方程组又可以计算出一组 <spanclass="math inline">\(t_{min}\)</span> 和 <spanclass="math inline">\(t_{max}\)</span>。</li><li>求解三组 <span class="math inline">\(t_{min}\)</span> 和 <spanclass="math inline">\(t_{max}\)</span>各自构成的线段，求线段的交集即可得到光线在包围盒内的传播路径，传播路径的两个端点正是光线与包围盒的两个交点。</li></ul><p>最终求解得到的 <span class="math inline">\(t_{enter}\)</span> 和<span class="math inline">\(t_{exit}\)</span>的值可能存在一下几种情况：</p><ul><li>当 <span class="math inline">\(t_{exit}\)</span> &lt; 0时，表示包围盒在光源的背后，没有交点。</li><li>当 <span class="math inline">\(t_{enter}\)</span> &lt; 0 且 <spanclass="math inline">\(t_{exit}\)</span> &gt;= 0时，表示光线在包围盒的内部。</li><li>当且仅当 <span class="math inline">\(t_{enter}\)</span> &lt; <spanclass="math inline">\(t_{exit}\)</span>，<spanclass="math inline">\(t_{exit}\)</span> &gt;= 0时，光线和包围盒存在交点。</li></ul><h3 id="统一网格">统一网格</h3><p>统一网格（Uniformgrids）是包围盒技术的延伸，它对包围盒内部的空间进行预处理。</p><ul><li>将包围盒拆分成统一的立体网格</li><li>将与物体发生重叠的网格进行标识</li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-15.png?x-oss-process=image/resize,w_800" /></p><p>在判断光线与包围盒内部物体的交点时，会先遍历所有网格。当网格与光线有交点时，会继续判断网格内的物体是否与光线有交点。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-16.png?x-oss-process=image/resize,w_800" /></p><p>关于网格的划分，不同的划分方法，加速效果存在差异。</p><ul><li>当只有一个网格时，没有加速效果。</li><li>当网格划分非常密集时，计算交点时遍历网格的开销会增大，甚至会出现性能降低的情况。</li></ul><p>相对而言，一个加速效果良好的网格划分经验是：网格的数量等于物体的数量乘以一个常数（经验值是27）。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-17.png?x-oss-process=image/resize,w_800" /></p><p>然而，统一网格划分并不是万能的，它只适用于一些物体分布均匀的场景。如下所示的场景中，在餐桌上方存在大量没有物体的空间，此时使用统一网格划分的效果并不好。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-18.png?x-oss-process=image/resize,w_800" /></p><h3 id="空间划分">空间划分</h3><p>空间划分（Spatialpartitions）主要是为了解决空间中物体分布不均匀的问题，其主要有<strong>Oct-Tree</strong>、<strong>KD-Tree</strong>、<strong>BSP-Tree</strong>等几种技术。</p><p>Oct-Tree，又称八叉树，其基本原理是把立方体分割成八等份，递归进行分割，直到格子为空或者物体足够少。</p><p>KD-Tree，与 Oct-Tree类似，区别在于每次只将格子一分为二，总是沿着有个轴进行分割。相比于Oct-Tree，其节点数量的复杂度不会随着维度而指数型增长。</p><p>BSP-Tree，对空间一分为二，每次选择一个方向进行分割。相比于KD-Tree，其切割的方向并不一定与轴平行。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-19.png?x-oss-process=image/resize,w_800" /></p><h4 id="kd-tree">KD-Tree</h4><p>这里我们着重介绍一下 KD-Tree 的工作原理。</p><p>首先，KD-Tree会对包围盒空间进行预处理，对包围盒空间进行二分，同时构建二叉树，如下所示。其中，非叶子节点表示二分之前的整体空间，它不会存储物体；叶子节点表示存储物体的真实空间。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-20.png?x-oss-process=image/resize,w_800" /></p><p>在进行交点判定时，本质上就是对二叉树进行先序遍历。对于上图中的例子，我们判定光线是否与整体的空间A 有交点。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-22.png?x-oss-process=image/resize,w_800" /></p><p>显然，上述例子中光线与空间 A 存在交点，那么它会节点 A 的两个叶子节点1 和 B分别进行交点判定，如下所示。如果交点存在则进一步判断内部物体是否与光线有交点。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-23.png?x-oss-process=image/resize,w_800" /></p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-28.png?x-oss-process=image/resize,w_800" /></p><p>下图所示，当与空间 B 存在交点时，则进一步遍历空间 B 的子节点。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-24.png?x-oss-process=image/resize,w_800" /></p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-25.png?x-oss-process=image/resize,w_800" /></p><p>以此类推，空间 C与光线仍然存在交点，则继续遍历二叉树，直到判定光线与物体之间存在交点或不存在交点。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-26.png?x-oss-process=image/resize,w_800" /></p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-27.png?x-oss-process=image/resize,w_800" /></p><p>直观而言，KD-Tree 确实能够有效加速交点判定。但是 KD-Tree也存在一些无法解决的问题，比如：</p><ul><li>存在无法判定的例子，比如：三角形反包围了包围盒。</li><li>同一物体被划分在多个包围盒中，会有重复判断的问题。</li></ul><h3 id="物体划分">物体划分</h3><p>针对空间划分无法解决的问题，图形学采用物体划分来解决。下面，我们来介绍物体划分的典型技术——BVH。</p><h4 id="bvh">BVH</h4><p>BVH（Bounding Volume Hierarchy）的整体原理与 KD-Tree类似，唯一的区别在于对于空间划分的方式不同。KD-Tree是对空间进行划分，BVH 是对物体进行划分。</p><p>如下所示，空间中的物体彼此之间非常靠近。如果我们采用 KD-Tree的划分方式，那么一个物体可能会包含在多个空间中，进而存在重复判定的情况。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-29.png?x-oss-process=image/resize,w_800" /></p><p>对此，BVH对物体进行划分，并重新计算包围盒，形成根节点的子节点。以此类推，最终在所有叶子节点存储物体列表。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-30.png?x-oss-process=image/resize,w_800" /></p><h2 id="交点渲染原理">交点渲染原理</h2><p>当我们获取到光线直射、反射至物体表面的交点之后，我们可以进一步计算这些交点的着色，从而完成渲染。</p><h3 id="光线追踪的问题">光线追踪的问题</h3><p>关于经典光线追踪，即 Whitted-Style RayTracing，对光线作出了如下假设。</p><ul><li><strong>光线只进行镜面反射和折射</strong></li><li><strong>光线在漫反射面停止弹射</strong></li></ul><p>对于第一种情况，经典光线追踪只能渲染镜面反射，无法渲染磨砂反射，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-33.png?x-oss-process=image/resize,w_800" /></p><p>对于第二种情况，对于漫反射物体，光线转播至表面时会停止弹射，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-34.png?x-oss-process=image/resize,w_800" /></p><p>既然经典光线追踪是存在问题的，那么我们该如何对交点进行渲染呢？答案是渲染方程。</p><h3 id="渲染方程定义">渲染方程定义</h3><p>关于渲染，我们在 <ahref="http://chuquan.me/2024/04/20/foundation-of-computer-graphic-07/">上一篇文章</a>中介绍了辐射度量学，文中我们提到了渲染方程，如下所示。</p><span class="math display">\[\begin{aligned}L_r(p, {\omega}_r) = L_e(p, {\omega}_r) + \int_{H^2}L_i(p, {\omega}_i)f_r(p, {\omega}_i, {\omega}_r) (n \cdot {\omega}_i) d{\omega}_i\end{aligned}\]</span><h3 id="渲染方程展开">渲染方程展开</h3><p>渲染方程经过一系列变换可以简化成如下表达式。</p><span class="math display">\[\begin{aligned}l(u) = e(u) + \int l(v) K(u, v) dv\end{aligned}\]</span><p>我们将 <span class="math inline">\(K\)</span>作为反射操作符，可以进一步将简化为如下表达式，<spanclass="math inline">\(L\)</span> 是一个递归项。</p><span class="math display">\[\begin{aligned}L = E + KL\end{aligned}\]</span><p>我们的目的是求解 <span class="math inline">\(L\)</span>，前面说 <spanclass="math inline">\(K\)</span>是一个反射操作符，其实我们也可以将其理解为反射次数，反射次数越多，展开项越多，比如：<spanclass="math inline">\(K^2\)</span> 表示光在空间中弹射两次。</p><span class="math display">\[\begin{aligned}L = &amp; E + KL\\IL - KL = &amp; E\\(I - K)L = &amp; E\\L = &amp; (I - K)^{-1}E\\L = &amp; (I + K + K^2 + K^3 + ...)E\\L = &amp; E + KE + K^{2}E + K^{3}E + ...\\\end{aligned}\]</span><p>我们可以非常容易地理解展开后的渲染方程中的各个项，如下所示。全局光照由<strong>光源</strong>、<strong>直接光照</strong>、<strong>间接光照</strong>组合而成，其中光栅化只包含了光源和直接光照两部分，难以实现间接光照；光线追踪则包含了全局光照。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-31.png?x-oss-process=image/resize,w_800" /></p><p>下图所示，展示了全局光照包含不同数量的光照项的渲染对比效果。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-32.png?x-oss-process=image/resize,w_800" /></p><h3 id="求解理论基础">求解理论基础</h3><p>关于如何求解渲染方程，我们首先介绍两部分与之相关的内容。</p><ul><li>概率论基础</li><li>蒙特卡洛积分</li></ul><h4 id="概率论基础">概率论基础</h4><p>这里我们主要介绍概率、概率分布函数、期望等几个概念。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-35.png?x-oss-process=image/resize,w_800" /></p><p>上图所示是一个的连续的概率分布曲线 <span class="math inline">\(X \simp(x)\)</span>，横坐标表示目标值 <spanclass="math inline">\(x\)</span>，纵坐标表示目标值为 <spanclass="math inline">\(x\)</span> 的概率 <spanclass="math inline">\(p(x)\)</span>，即<strong>概率分布函数</strong>（Probability DistributionFunction，PDF），那么我们可以得到如下两个关系式：</p><ul><li>所有取值的概率之和为 <span class="math inline">\(1\)</span></li><li>期望为概率与目标值乘积的积分 <spanclass="math inline">\(E[X]\)</span></li></ul><span class="math display">\[\begin{aligned}\int p(x) dx = &amp; 1; &amp;&amp;  p(x) \geq 0\\E[X] = &amp; \int x p(x) dx\end{aligned}\]</span><h4 id="蒙特卡洛积分">蒙特卡洛积分</h4><p>假设我们希望求解如下所示的不定积分，那么该如何求解？答案是蒙特卡洛积分。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-36.png?x-oss-process=image/resize,w_800" /></p><p>蒙特卡洛积分（Monte CarloIntegration）的基本思想是：在积分域内不断采样 <spanclass="math inline">\(f(x)\)</span>，不断地与 <spanclass="math inline">\(ab\)</span>构成一个个长方形，然后对所有的长方形的面积之和求平均值。由此我们可以得到离散形式的蒙特卡洛方程，如下所示。</p><span class="math display">\[\begin{aligned}F_N = \frac{1}{N} \sum^{1}_{i=1} \frac{f(x_i)}{p(x_i)}\end{aligned}\]</span><p>我们将其进一步表示为更加通用的连续形式的蒙特卡洛方程，如下所示。</p><span class="math display">\[\begin{aligned}\int_{a}^{b} f(x) dx = \frac{1}{N} \sum_{i=1}^{N} \frac{f(x_i)}{p(x_i)}\end{aligned}\]</span><p>对于蒙特卡洛积分，当 <span class="math inline">\(N\)</span>越大，求解的结果越精准。</p><h3 id="渲染方程求解">渲染方程求解</h3><p>在了解了概率论基础和蒙特卡洛积分之后，我们来正式求解渲染方程。</p><h4 id="求解过程分析">求解过程分析</h4><p>假设，我们只考虑一个像素点的着色过程，如下图所示。其中，<spanclass="math inline">\({\omega}_o\)</span>为观测方向，即从着色点到观测点的方向；<spanclass="math inline">\({\omega}_i\)</span>为光线入射方向，即从光源到着色点的方向。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-37.png?x-oss-process=image/resize,w_800" /></p><p>同时，我们先忽略着色点本身的发光项，那么可以得到一个简化的渲染方程，如下所示。</p><span class="math display">\[\begin{aligned}L_o(p, {\omega}_o) = \int_{H^2} L_i(p, {\omega}_i) f_r(p, {\omega}_i,{\omega}_o) (n \cdot {\omega}_i) d{\omega}_i\end{aligned}\]</span><p>我们将渲染等价为在半球面上进行采样。因此，任意一点的采样概率 <spanclass="math inline">\(p({\omega}_i)\)</span> 为 <spanclass="math inline">\(1/2\pi\)</span>，即概率分布函数（PDF）。对应，采样值<span class="math inline">\(f({\omega}_i)\)</span> 为 <spanclass="math inline">\(L_i(p, {\omega}_i) f_r(p, {\omega}_i, {\omega}_o)(n \cdot{\omega}_i)\)</span>。由此可以用蒙特卡洛积分来求解渲染方程，如下所示。</p><span class="math display">\[\begin{aligned}L_o(p, {\omega}_o) = &amp;\int_{H^2}L_i(p, {\omega}_i) f_r(p, {\omega}_i, {\omega}_o) (n \cdot{\omega}_i) d{\omega}_i\\\approx &amp;\frac{1}{N} \sum_{i=1}^{N} \frac{f({\omega}_i)}{p({\omega}_i)}\\\approx &amp;\frac{1}{N} \sum_{i=1}^{N} \frac{L_i(p, {\omega}_i) f_r(p, {\omega}_i,{\omega}_o) (n \cdot {\omega}_i) }{p({\omega}_i)}\end{aligned}\]</span><p>根据上述的渲染方程的求解关系式，我们可以实现对应的伪代码，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-38.png?x-oss-process=image/resize,w_800" /></p><p>上述，我们只处理了光的一次弹射，而全局光照还包括二次弹射、三次弹射...我们该如何处理呢？</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-39.png?x-oss-process=image/resize,w_800" /></p><p>上图所示，我们希望计算 Q 点反射到 P点的光线。对此，我们可以转换一下思路：Q 点反射到 P 点的光线，不就等于从P 点观测 Q 点，Q点的着色吗？于是全局光照就转换成了一个递归着色的过程。如下所示，我们加入了处理全局光照的递归着色。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-40.png?x-oss-process=image/resize,w_800" /></p><p>至此，着色算法的整体框架已经实现了，但是还存在两个问题：</p><ul><li>分治爆炸问题</li><li>无限递归问题</li></ul><h4 id="分治爆炸问题">分治爆炸问题</h4><p>在上述算法实现中，对于每一个点的着色，我们都会采样各个方向，而每一个方向所经过的反射点又会采样各个方向。如果我们对于每个点采样100 个方向，那么在下一次递归中将会采样 10000个方向，依次类推，产生分治爆炸问题。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-41.png?x-oss-process=image/resize,w_800" /></p><p>那么如何解决这个问题？很显然，只有当采样数量为 1时，递归时才不会出现分治爆炸问题。在计算机图形学中，对于每个着色点，只通过一条射线进行光线追踪的方式被称为<strong>路径追踪</strong>（Path Tracing）。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-42.png?x-oss-process=image/resize,w_800" /></p><p>如下所示，为解决了分支爆炸问题的着色算法。我们不再采样各个方向，而是随机采样一个方向，进而计算着色。然而，我们知道在蒙特卡洛积分中，采样数越小，准确性越低。算法中将采样数降至1，很显然，会出现很大的噪声。那么这又该如何解决呢？</p><p>对此，有一种方法另辟蹊径：<strong>每个像素生成多条光线，进行多次路径追踪，并求解平均值</strong>。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-43.png?x-oss-process=image/resize,w_800" /></p><p>如下所示，我们定义了一个 <code>ray_generation</code>方法，它以相机位置和像素点作为参数，内部对这个像素点进行多次路径追踪，即调用<code>shade</code> 方法，最终求解平均值。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-44.png?x-oss-process=image/resize,w_800" /></p><h4 id="无限递归问题">无限递归问题</h4><p>对于无限递归问题，很显然，我们必须要找到一个停止递归的策略。对此，图形学借鉴了<strong>俄罗斯轮盘赌</strong>（Russion Roulette，RR）的思想。</p><p>俄罗斯轮盘赌的基本原理是设定一个概率值 <span class="math inline">\(0&lt; P &lt; 1\)</span>。</p><ul><li>射出光线的概率为 <spanclass="math inline">\(P\)</span>，由此返回的着色结果为 <spanclass="math inline">\(L_0/P\)</span></li><li>不射出光线的概率为 <spanclass="math inline">\(1-P\)</span>，由此返回的着色结果为 <spanclass="math inline">\(0\)</span></li></ul><p>使用这种方式，我们仍然可以期望得到 <spanclass="math inline">\(L_0\)</span>，如下所示为俄罗斯轮盘赌的期望公式.</p><span class="math display">\[\begin{aligned}E = P * (L_0 / P) + (1 - P) * 0 = L_0\end{aligned}\]</span><p>于是，我们可以进一步优化着色算法，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-45.png?x-oss-process=image/resize,w_800" /></p><h4 id="光源采样问题">光源采样问题</h4><p>经过上述改进，着色算法是正确了，但是它并不高效。如下所示，像素采样必须达到一定阈值才能得到相对高质量的结果。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-46.png?x-oss-process=image/resize,w_800" /></p><p>这里的根本原因在于均匀采样，其中只有极少数方向能够打到光源，大多数方向则浪费掉了。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-47.png?x-oss-process=image/resize,w_800" /></p><p>对此，我们可以考虑直接对光源进行采样。考虑到我们在求解蒙特卡洛积分时是在立体角进行采样，因此我们将空间中的面光源投影到球面上，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-48.png?x-oss-process=image/resize,w_800" /></p><p>我们得到立体角和光源微分面积的关系如下所示。</p><span class="math display">\[\begin{aligned}d\omega = \frac{dA cos{\theta}&#39;}{||x&#39; - x||^2}\end{aligned}\]</span><p>然后，我们微分立体角代入渲染方程，如下所示。</p><span class="math display">\[\begin{aligned}L_o(p, {\omega}_o) = &amp;\int_{H^2}L_i(p, {\omega}_i) f_r(p, {\omega}_i, {\omega}_o) cos\thetad{\omega}_i\\= &amp;\int_{H^2} L_i(p, {\omega}_i) f_r(p, {\omega}_i, {\omega}_o)\frac{cos\theta cos{\theta}&#39;}{||x&#39; - x||^2} dA\end{aligned}\]</span><p>现在，我们认为着色结果来源于两部分：</p><ul><li>光源的直接作用：直接采样光源，无需俄罗斯轮盘赌</li><li>光源的间接作用：反射、漫反射，需要俄罗斯轮盘赌</li></ul><p>由此，我们结合两部分得到如下所示的优化算法。当然，对于光源的直接作用，我们需要判断光源与着色点之间是否存在遮挡，这一点一定要注意。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/ray-tracing-49.png?x-oss-process=image/resize,w_800" /></p><h1 id="总结">总结</h1><p>本文我们主要介绍了光线追踪的原理。首先，我们我们介绍了光线追踪的基本原理，其主要包括：视线生成和像素着色两个阶段。</p><p>然后，我们介绍了经典光线追踪技术，也称为 Whitted-Style光线追踪，其引入了间接光照的处理。</p><p>在光线追踪中，主要设计两个关键技术，分别是交点判定和交点渲染。对于交点判定，我们介绍了包围盒原理、统一网格、空间划分、物体划分等技术；对于交点渲染，我们在经典光线追踪的基础上引入了漫反射的处理，整体围绕渲染方程进行展开，使用蒙特卡洛积分进行求解。</p><p>在实现渲染方程算法的过程中，我们遇到了分治爆炸、无限递归、光源采样等问题，对此我们也依次进行了处理。最终实现了一个完整的着色算法。</p><h1 id="参考">参考</h1><ol type="1"><li>《GAMES 101》</li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;之前我们介绍了在游戏领域中广泛使用的实时渲染技术——光栅化，本文我们来介绍一下在特效领域中广泛使用的离线渲染技术——光线追踪。&lt;/p&gt;</summary>
    
    
    
    <category term="图形学" scheme="http://chuquan.me/categories/%E5%9B%BE%E5%BD%A2%E5%AD%A6/"/>
    
    
    <category term="Whitted-Stype Ray Tracing" scheme="http://chuquan.me/tags/Whitted-Stype-Ray-Tracing/"/>
    
    <category term="交点判定" scheme="http://chuquan.me/tags/%E4%BA%A4%E7%82%B9%E5%88%A4%E5%AE%9A/"/>
    
    <category term="交点渲染" scheme="http://chuquan.me/tags/%E4%BA%A4%E7%82%B9%E6%B8%B2%E6%9F%93/"/>
    
    <category term="包围盒" scheme="http://chuquan.me/tags/%E5%8C%85%E5%9B%B4%E7%9B%92/"/>
    
    <category term="轴对齐包围盒" scheme="http://chuquan.me/tags/%E8%BD%B4%E5%AF%B9%E9%BD%90%E5%8C%85%E5%9B%B4%E7%9B%92/"/>
    
    <category term="KD-Tree" scheme="http://chuquan.me/tags/KD-Tree/"/>
    
    <category term="BVH" scheme="http://chuquan.me/tags/BVH/"/>
    
    <category term="蒙特卡洛积分" scheme="http://chuquan.me/tags/%E8%92%99%E7%89%B9%E5%8D%A1%E6%B4%9B%E7%A7%AF%E5%88%86/"/>
    
    <category term="路径追踪" scheme="http://chuquan.me/tags/%E8%B7%AF%E5%BE%84%E8%BF%BD%E8%B8%AA/"/>
    
    <category term="光线追踪" scheme="http://chuquan.me/tags/%E5%85%89%E7%BA%BF%E8%BF%BD%E8%B8%AA/"/>
    
    <category term="俄罗斯轮盘赌" scheme="http://chuquan.me/tags/%E4%BF%84%E7%BD%97%E6%96%AF%E8%BD%AE%E7%9B%98%E8%B5%8C/"/>
    
  </entry>
  
  <entry>
    <title>计算机图形学基础（7）——辐射度量学</title>
    <link href="http://chuquan.me/2024/04/20/foundation-of-computer-graphic-07/"/>
    <id>http://chuquan.me/2024/04/20/foundation-of-computer-graphic-07/</id>
    <published>2024-04-20T14:11:09.000Z</published>
    <updated>2024-04-20T14:13:37.201Z</updated>
    
    <content type="html"><![CDATA[<p>在介绍光线追踪之前，我们先来学习一下其所涉及的重要内容——辐射度量学。由于该内容相对独立，这里单开一篇文章来进行介绍。</p><span id="more"></span><h1 id="概述">概述</h1><p>本质上，辐射度量学是在物理层面准确定义光照的方法。在 <ahref="http://chuquan.me/2024/04/05/foundation-of-computer-graphic-05/">《计算机图形学基础（5）——着色》</a>一文中我们提到了光照强度，我们将半径为 <spanclass="math inline">\(r\)</span> 时，对应的球面的一个点的光照强度为<spanclass="math inline">\(I\)</span>，那么具体它的单位是什么呢？在了解辐射度量学之后，这个疑问就能得到解答。</p><p>在辐射度量学中涉及了很多相关术语，这里我将它们分为两部分进行介绍。</p><h1 id="几何">几何</h1><p>首先我们来看几何的相关术语，主要包含以下几个：</p><ul><li><strong>角（Angles）</strong></li><li><strong>立体角（Solid Angles）</strong></li><li><strong>微分立体角（Differential Solid Angles）</strong></li></ul><h2 id="角">角</h2><p>在平面几何中，角的定义是弧长与半径的比值，其中 <spanclass="math inline">\(\theta\)</span> 表示角，<spanclass="math inline">\(l\)</span> 表示弧长，<spanclass="math inline">\(r\)</span> 表示圆的半径，如下所示。圆自身的角度为<span class="math inline">\(2\pi\)</span>。</p><span class="math display">\[\begin{aligned}\theta = \frac{l}{r}\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/radiometry-01.png?x-oss-process=image/resize,w_800" /></p><h2 id="立体角">立体角</h2><p>在立体几何中，立体角的定义则是弧面积与半径平方的比值，其中 <spanclass="math inline">\(\Omega\)</span> 表示立体角，<spanclass="math inline">\(A\)</span> 表示弧面积，<spanclass="math inline">\(r\)</span>表示球的半径，如下所示。球自身的立体角为 <spanclass="math inline">\(4\pi\)</span>。立体角主要用于描述空间中的一个锥体的张开角度。</p><span class="math display">\[\begin{aligned}\Omega = \frac{A}{r^2}\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/radiometry-02.png?x-oss-process=image/resize,w_800" /></p><h2 id="微分立体角">微分立体角</h2><p>微分立体角通过两个角度来定义球面上的一个唯一的方向，如下所示。其中，两个角度分别是：</p><ul><li>与 <span class="math inline">\(z\)</span> 轴形成的夹角 <spanclass="math inline">\(\theta\)</span></li><li>绕 <span class="math inline">\(z\)</span> 轴形成的夹角 <spanclass="math inline">\(\phi\)</span></li></ul><p>计算微分立体角，我们首先两个角度在球面所形成方向所占据的单位面积<spanclass="math inline">\(dA\)</span>。按照微分的思想，我们认为该面积等同于矩形的面积，因此我们要计算它的长和宽，分别为<span class="math inline">\(rsin{\theta}d\phi\)</span> 和 <spanclass="math inline">\(rd\theta\)</span>，由此可以计算该单位面积。从而进一步求解微分立体角<span class="math inline">\(d\omega\)</span>，如下所示。</p><span class="math display">\[\begin{aligned}dA = &amp;(r d\theta)(r sin{\theta} d\phi) = r^2 sin\theta d\theta d\phi\\d\omega = &amp;\frac{dA}{r^2} = sin\theta d\theta d\phi\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/radiometry-03.png?x-oss-process=image/resize,w_800" /></p><p>在辐射度量学中，我们通常用 <spanclass="math inline">\(\omega\)</span>来表示三维空间中的一个方向。我们可以使用 <spanclass="math inline">\(\theta\)</span> 和 <spanclass="math inline">\(\phi\)</span> 来确定其方向，并且还可以结合 <spanclass="math inline">\(d\theta\)</span> 和 <spanclass="math inline">\(d\phi\)</span> 来计算其微分立体角。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/radiometry-04.png?x-oss-process=image/resize,w_800" /></p><h1 id="光学物理">光学物理</h1><p>在辐射度量学中，主要涉及以下几个光学物理相关术语。</p><ul><li><strong>辐射能量（Radiant Energy）</strong></li><li><strong>辐射通量（Radiant Flux）</strong></li><li><strong>辐射强度（Radiant Intensity）</strong></li><li><strong>辐射照度（Irradiance）</strong></li><li><strong>辐射亮度（Radiance）</strong></li></ul><h2 id="辐射能量">辐射能量</h2><p>辐射能量（Radiant Energy）表示电磁辐射的能量，用符号 <spanclass="math inline">\(Q\)</span> 表示，以 <strong>焦耳</strong>（J =Joule）为单位。</p><span class="math display">\[\begin{aligned}Q\\unit: [J = Joule]\end{aligned}\]</span><h2 id="辐射通量">辐射通量</h2><p>辐射通量（RadiantFlux）表示单位时间内流通（发射、反射、传输、接收）的能量，用符号 <spanclass="math inline">\(\Phi\)</span> 表示，以 <strong>瓦特</strong>（W =Watt）或 <strong>流明</strong>（lm =lumen）为单位。<strong>在实际应用中，我们经常将辐射通量称为能量，本质上是因为辐射通量可以结合时间快速计算出辐射能量</strong>。</p><span class="math display">\[\begin{aligned}\Phi = \frac{dQ}{dt}\\unit: [W = Watt]/[lm = lumen]\end{aligned}\]</span><p>在光学物理中，我们也将辐射通量定义为<strong>单位时间辐射出光子的数量</strong>。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/radiometry-05.png?x-oss-process=image/resize,w_800" /></p><h2 id="辐射强度">辐射强度</h2><p>辐射强度（Radiant Intensity）表示<strong>单位立体角的辐射通量</strong>。其中 <spanclass="math inline">\(\Phi\)</span> 表示辐射通量，<spanclass="math inline">\(\omega\)</span>表示立体角。我们可以用辐射强度描述一束光线，即形成一个锥体角度的光线，在单位时间内流通的能量。</p><span class="math display">\[\begin{aligned}I(\omega) = \frac{d\Phi}{d\omega}\\unit:[\frac{W}{sr}][\frac{lm}{sr} = cd = candela]\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/radiometry-06.png?x-oss-process=image/resize,w_800" /></p><h2 id="辐射照度">辐射照度</h2><p>辐射照度（Irradiance）表示<strong>单位（正交）面积的辐射通量</strong>。注意，这里计算的辐射通量必须与面垂直，如果光照不垂直于面，则计算其垂直分量。其中<span class="math inline">\(\Phi\)</span> 表示辐射通量，<spanclass="math inline">\(A\)</span> 表示正交面积，$$ 表示位置。</p><span class="math display">\[\begin{aligned}E(p) = \frac{d\Phi(p)}{dA}\\unit: [\frac{W}{m^2}][\frac{lm}{m^2} = lux]\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/radiometry-07.png?x-oss-process=image/resize,w_800" /></p><p>此时，我们回顾 <ahref="http://chuquan.me/2024/04/05/foundation-of-computer-graphic-05/">《计算机图形学基础（5）——着色》</a>文中的兰伯特余弦定理，可以计算辐射照度，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/radiometry-08.png?x-oss-process=image/resize,w_800" /></p><p>同样，我们还可以计算空间中一个点光源在空间中辐射的衰减到底是什么？如下所示，其实我们可以发现辐射照度随着半径增加而指数级衰减，辐射强度并没有发生变化。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/radiometry-09.png?x-oss-process=image/resize,w_800" /></p><h2 id="辐射亮度">辐射亮度</h2><p>辐射亮度（Radiance）表示<strong>单位立体角、单位（正交）面积的辐射通量</strong>。辐射亮度一般用于描述光线在传输过程中的属性。我们可以用辐射亮度来描述某个面光源在某个（锥形）方向的辐射通量。</p><p>从定义上看，辐射亮度在立体角、正交面积两个维度做了两次微分。其中，<spanclass="math inline">\(cos\theta\)</span>表示光线在单位面积垂直方向上的角度分量，<spanclass="math inline">\(p\)</span> 表示位置，<spanclass="math inline">\(\omega\)</span> 表示立体角。</p><span class="math display">\[\begin{aligned}L(p, \omega) = \frac{d^2\Phi(p, \omega)}{d{\omega}dA cos\theta}\\unit: [\frac{W}{sr m^2}][\frac{cd}{m^2} = \frac{lm}{sr m^2} = nit]\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/radiometry-10.png?x-oss-process=image/resize,w_800" /></p><p>下面，我们回顾一下辐射强度和辐射照度的定义。</p><ul><li>辐射强度：单位立体角的辐射通量。</li><li>辐射照度：单位（正交）面积的辐射通量</li></ul><p>结合辐射强度和辐射照度的定义，我们可以将辐射亮度的定义进行转换，<strong>单位立体角的辐射照度</strong>或<strong>单位（正交）面积的辐射强度</strong>。这两种定义正好可以应用到两种不同方向的辐射亮度，分别是<strong>入射辐射亮度</strong> 和 <strong>出射辐射亮度</strong>。</p><h3 id="入射辐射亮度">入射辐射亮度</h3><p>入射辐射亮度（IncidentRadiance）即单位立体角的辐射照度。我们可以理解从某个方向向一个面进行辐射，该面所接收的辐射照度，如下图所示。如果我们进一步考虑来自四面八方的辐射，比如环境光，那么可以计算得到这个面所接收的全部辐射照度。</p><span class="math display">\[\begin{aligned}L(p, \omega) = \frac{dE{p}}{d{\omega} cos\theta}\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/radiometry-11.png?x-oss-process=image/resize,w_800" /></p><h3 id="出射辐射亮度">出射辐射亮度</h3><p>出射辐射亮度（ExitingRadiance）即单位（正交）面积的辐射强度。我们可以理解一个面向某个方向进行辐射，该方向所发射的辐射强度，如下所示。如果我们进一步考虑一个面向四面八方的辐射，那么可以计算得到这个面所发射的全部辐射强度。</p><span class="math display">\[\begin{aligned}L(p, \omega) = \frac{dI(p, \omega)}{dA cos\theta}\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/radiometry-10.png?x-oss-process=image/resize,w_800" /></p><h2 id="辐射照度-vs.-辐射亮度">辐射照度 vs. 辐射亮度</h2><p>在图形学中，辐射照度（Irradiance）和辐射亮度（Radiance）用的非常多。在下图所示的场景中，辐射照度表示<span class="math inline">\(dA\)</span> 接收的所有能量；辐射亮度表示<span class="math inline">\(dA\)</span>从某个方向接收的能量。相比而言，辐射亮度是一个更细粒度的分析属性。这样，我们就把辐射照度和辐射亮度联系起来了。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/radiometry-12.png?x-oss-process=image/resize,w_800" /></p><p>对于上图所示的场景，我们可以计算得到某个位置的辐射照度，如下所示，其中<span class="math inline">\(H^2\)</span> 表示单位半球面积。</p><span class="math display">\[\begin{aligned}dE(p, \omega) = &amp;L_{i}(p, \omega) cos\theta d\omega\\E(p) = &amp;\int_{H^2} L_{i}(p, \omega) cos\theta d{\omega}\end{aligned}\]</span><h1 id="应用">应用</h1><h2 id="双反射分布函数">双反射分布函数</h2><p>已知入射光线的能量和角度，当辐射到物体表面后，光线会向各个角度反射，且各个角度反射的能量是不同的。双反射分布函数（BidirectionalReflectance DistributionFunction，BRDF）就是用于计算这种场景下各个角度的反射能量。</p><p>如下所示，是一个入射和反射的场景，我们从辐射度量学的角度分别进行分析。</p><ul><li>入射阶段：当入射光线从 <spanclass="math inline">\({\omega}_i\)</span>角度辐射到物体表面的一个位置时，可以认为该位置吸收了所有的光线能量，用辐射照度来表示，即<span class="math inline">\(dE({\omega}_i)\)</span>。</li><li>反射阶段：可以认为该位置将光线能量向各个方向进行辐射。对于方向 <spanclass="math inline">\({\omega}_i\)</span>的光线能量，用辐射亮度来表示，即 <span class="math inline">\(dL_r(x,{\omega}_r)\)</span>。</li></ul><span class="math display">\[\begin{aligned}dE({\omega}_i) = &amp;L({\omega}_i) cos{\theta}_i d{\omega}_i\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/radiometry-13.png?x-oss-process=image/resize,w_800" /></p><p>本质上，BRDF 就是表示由入射方向 <spanclass="math inline">\({\omega}_i\)</span> 向反射方向 <spanclass="math inline">\({\omega}_r\)</span> 辐射能量的比例函数 <spanclass="math inline">\(fr({\omega}_i \rightarrow{\omega}_r)\)</span>，其定义如下所示。</p><span class="math display">\[\begin{aligned}BRDF: f_r({\omega}_i \rightarrow {\omega}_r) =\frac{dL_r({\omega}_r)}{dE_i({\omega}_i)} =\frac{dL_r({\omega}_r)}{L_i({\omega}_i) cos{\theta}_i d{\omega}_i}\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/radiometry-14.png?x-oss-process=image/resize,w_800" /></p><p>在实际应用中，对于镜面反射，BRDF定义反射方向包含所有能量，其他方向均为 0；对于漫反射，BRDF定义所有方向的能量分布相同。事实上，BRDF就是描述了光线和物体表面的作用，也就是决定了物体的材质。</p><h2 id="反射方程">反射方程</h2><p>基于 BRDF，我们可以进一步推导出 <strong>反射方程</strong>（TheReflection Equation）。</p><p>由于 BRDF 定义了一个入射方向 <spanclass="math inline">\({\omega}_i\)</span> 向一个反射方向 <spanclass="math inline">\({\omega}_r\)</span>反射时的能量比例。那么，我们可以通过积分计算所有方向对一个反射方向 <spanclass="math inline">\({\omega}_r\)</span>的能量聚合。因此，我们可以推导得出如下所示的反射方程。</p><span class="math display">\[\begin{aligned}L_r(p, {\omega}_r) = \int_{H^2}f_r(p, {\omega}_i \rightarrow {\omega}_r)L_i(p, {\omega}_i) cos{\theta}_i d{\omega}_i\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/radiometry-14.png?x-oss-process=image/resize,w_800" /></p><h2 id="渲染方程">渲染方程</h2><p>基于反射方程，我们还可以进一步推导出 <strong>渲染方程</strong>（TheRendering Equation）。</p><p>这里我们只需要额外考虑物体本身会发光的情况，于是增加一个发光项即可得到渲染方程，如下所示。其中，<spanclass="math inline">\(cos{\theta}_i\)</span>转换成了法向量和入射方向的点积 <span class="math inline">\(n \cdot{\omega}_i\)</span>。现代图形学中，所有基于表面反射的渲染都是基于该渲染方程实现的。</p><span class="math display">\[\begin{aligned}L_r(p, {\omega}_r) = L_e(p, {\omega}_r) + \int_{H^2} L_i(p, {\omega}_i)f_r(p, {\omega}_i, {\omega}_r)(n \cdot {\omega}_i) d{\omega}_i\end{aligned}\]</span><h1 id="总结">总结</h1><p>本文我们主要介绍了辐射度量学中的几个概念：辐射能量、辐射通量、辐射强度、辐射照度、辐射亮度等。基于这些概念，我们引入了BRDF 的定义。然后依次推导出反射方程和渲染方程。</p><h1 id="参考">参考</h1><ol type="1"><li>《GAMES 101》</li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;在介绍光线追踪之前，我们先来学习一下其所涉及的重要内容——辐射度量学。由于该内容相对独立，这里单开一篇文章来进行介绍。&lt;/p&gt;</summary>
    
    
    
    <category term="图形学" scheme="http://chuquan.me/categories/%E5%9B%BE%E5%BD%A2%E5%AD%A6/"/>
    
    
    <category term="Radiance" scheme="http://chuquan.me/tags/Radiance/"/>
    
    <category term="Irradiance" scheme="http://chuquan.me/tags/Irradiance/"/>
    
    <category term="Intensity" scheme="http://chuquan.me/tags/Intensity/"/>
    
    <category term="立体角" scheme="http://chuquan.me/tags/%E7%AB%8B%E4%BD%93%E8%A7%92/"/>
    
    <category term="微分立体角" scheme="http://chuquan.me/tags/%E5%BE%AE%E5%88%86%E7%AB%8B%E4%BD%93%E8%A7%92/"/>
    
  </entry>
  
  <entry>
    <title>计算机图形学基础（6）——几何</title>
    <link href="http://chuquan.me/2024/04/10/foundation-of-computer-graphic-06/"/>
    <id>http://chuquan.me/2024/04/10/foundation-of-computer-graphic-06/</id>
    <published>2024-04-10T15:10:24.000Z</published>
    <updated>2024-04-11T01:02:20.747Z</updated>
    
    <content type="html"><![CDATA[<p>前面我们介绍了观测变换、光栅化、着色等几个图形学中比较复杂的主题，本文我们稍微放松一下，介绍一个相对比较简单的主题——几何。</p><span id="more"></span><h1 id="几何表示">几何表示</h1><p>通过图形学建模表示现实生活中的各种物体，要解决的第一个问题就是如何定义物体形状，而这就涉及到了几何。</p><p>物体的形状非常多，那么如何通过几何方法表示物体呢？对此，图形学中定义了两种几何表示方法：</p><ul><li><strong>隐式几何表示</strong>（Implicit Representations ofGeometry）</li><li><strong>显示几何表示</strong>（Explicit Representations ofGeometry）</li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-01.png?x-oss-process=image/resize,w_800" /></p><h2 id="隐式几何表示">隐式几何表示</h2><p>隐式几何表示是一种 <strong>使用数学关系式来描述几何形状</strong>的方法，而不是直接描述其顶点和边界等元素。在隐式几何表示法中，几何形状被定义为方程的解集，即满足某些条件的一组点的集合。比如，下面的关系式定义了一个圆环结构。</p><span class="math display">\[\begin{aligned}f(x, y, z) = (2 - \sqrt{x^2 + y^2})^2 + z^2 - 1\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-02.png?x-oss-process=image/resize,w_800" /></p><p>隐式几何表示的常用技术有以下这些：</p><ul><li><strong>代数曲面</strong>（Algebraic Surface）</li><li><strong>构造实体几何</strong>（Constructive Solid Geometry）</li><li><strong>距离函数</strong>（Distance Function）</li><li><strong>水平集</strong>（Level Set）</li><li><strong>分形</strong>（Fractals）</li></ul><p>下面，我们来介绍一下这些常用的隐式几何表示技术。</p><h3 id="代数曲面">代数曲面</h3><p>代数曲面是通过一组参数方程定义的曲线和表面。它适用于一些简单的，可以使用数学关系式表示的几何体。下图所示，这些几何体就比较适合使用代数曲面来表示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-04.png?x-oss-process=image/resize,w_800" /></p><h3 id="构造实体几何">构造实体几何</h3><p>构造实体几何是通过布尔运算来组合不同的几何体。下图所示，一些复杂的几何体可以通过简单的几何体来组合构造。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-05.png?x-oss-process=image/resize,w_800" /></p><h3 id="距离函数">距离函数</h3><p>距离函数描述空间中任何一个点到几何体表面的最小距离。一种特殊的距离函数，符号距离函数（SignedDistanceFunction），其以空间中任意一个点作为输入，根据距离函数的返回值，可以进行判断：</p><ul><li>当距离函数的值大于 0，表示点在几何体外部</li><li>当距离函数的值小于 0，表示点在几何体内部</li><li>当距离函数的值等于 0，表示点在几何体表面</li></ul><h3 id="水平集">水平集</h3><p>对于表面规则的几何体，我们可以使用距离函数来表示；对于表面复杂的几何体，距离函数难以适用，此时，我们可以使用水平集来表示。</p><p>水平集的核心思想与距离函数一样，区别在于：距离函数使用通过输入空间点来计算该点到几何体表面的距离，水平集则存储了一系列距离值，我们可以通过插值法找到距离为0 的位置，拟合出一条曲面用于表示几何体的表面。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-06.png?x-oss-process=image/resize,w_800" /></p><h3 id="分形">分形</h3><p>分形，类似于递归，即局部和整体的形状相似，如下图所示。分形通过迭代函数系统（IFS）来生成。IFS是一种迭代的过程，该过程将函数反复应用于某个起始点或起始数据。这些函数通常是缩放、旋转、平移等操作，同时保持自相似性。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-07.png?x-oss-process=image/resize,w_800" /></p><!--缺点：难以判断哪些点属于关系式--><!--优点：容易判断点是否在几何的表面--><h2 id="显示几何表示">显示几何表示</h2><p>显式几何表示是一种<strong>直接或间接（通过参数映射的方式）定义点、线、面等元素集合</strong>的方法。在显式几何表示中，各元素的位置通常由坐标值直接给出，各元素之间的关系通常由数据结构来表示。比如，下面的关系式通过参数映射的方式间接定义了点的集合。</p><span class="math display">\[\begin{aligned}f: R^2 \rightarrow &amp;R^3\\(u, v) \rightarrow &amp;(x, y, z)\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-03.png?x-oss-process=image/resize,w_800" /></p><p>显式几何表示的常用技术有以下这些：</p><ul><li><strong>点云</strong>（Point Cloud）</li><li><strong>网格模型</strong>（Polygon Mesh）</li></ul><p>下面，我们来介绍一下这两种显式几何表示技术。</p><!--缺点：难以判断是否在内部或外部--><h3 id="点云">点云</h3><p>点云是显式几何表示中最简单的技术，其核心思想是使用大量的点来表示几何体的表面。点的密度越高，几何体的精度越高。由于点云的缺点很明显，内存占用大，因此一般会被再次转换成多边形网格。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-08.png?x-oss-process=image/resize,w_800" /></p><h3 id="多边形网格">多边形网格</h3><p>多边形网格是图形学中最常用的几何表示方法，它存储点和多边形（一般是三角形或四边形），这种形式非常容易处理、模拟、采样。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-09.png?x-oss-process=image/resize,w_800" /></p><p>在 3D 建模中，我们经常会用到 <code>.obj</code>格式的模型文件，其本质上是一个文本文件，记录了顶点、法线、纹理坐标、连接关系，由此构成几何体的形状。如下所示，是一个立方体结构的表示。</p><ul><li><code>v</code> 表示顶点</li><li><code>vn</code> 表示法线（多了两条是因为建模误差）</li><li><code>vt</code> 表示纹理坐标</li><li><code>f</code> 表示面，比如 <code>f 5/1/1 1/2/1 4/3/1</code>表示三角形面是由第 5、1、4 个顶点组成，三个点的纹理坐标是第 1、2、3对应的纹理坐标，面的法线是第 1 条法线。</li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-10.png?x-oss-process=image/resize,w_800" /></p><h1 id="曲线">曲线</h1><p>曲线（Curves）在图形学中应用非常广泛，比如：相机的拍摄路径、物体的移动路径、动画曲线、矢量字体等。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-11.png?x-oss-process=image/resize,w_800" /></p><h2 id="贝塞尔曲线">贝塞尔曲线</h2><p>贝塞尔曲线是通过一系列控制点进行定义的曲线。如下图所示，4个控制点定义了一条贝塞尔曲线，起始方向沿着 <spanclass="math inline">\(p_0p_1\)</span>，结束方向沿着 <spanclass="math inline">\(p_2p_3\)</span>，曲线不必经过所有控制点，但必须经过起始点和结束点。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-12.png?x-oss-process=image/resize,w_800" /></p><!--特性：--><!--- 必须过起点和终点--><!--- 仿射不变性--><!--- 凸包性质--><h3 id="绘制算法">绘制算法</h3><p>那么控制点是如何影响曲线的呢？贝塞尔曲线绘制算法的原理是什么呢？</p><p>贝塞尔曲线的绘制算法是 <strong>De Casteljau'sAlgorithm</strong>，算法的基本思想是利用线性插值的原理，将高阶贝塞尔曲线转化为一阶贝塞尔曲线的组合。对于一个N阶贝塞尔曲线，首先构建一系列的二维点，然后在这些点上构建线段，以此类推，直到计算出贝塞尔曲线上的一个点。重复这个过程就可以得到贝塞尔曲线上的所有点，从而绘制出完整的贝塞尔曲线。</p><p>下面，我们以 3 个控制点绘制贝塞尔曲线的例子来进行介绍。</p><p>N 个控制点绘制的贝塞尔曲线，称为 <strong>N-1阶贝塞尔曲线</strong>。如下所示，我们定义了 3个控制点，由此绘制的贝塞尔曲线称之为<strong>二阶贝塞尔曲线</strong>（Quadratic Bezier）。对于这 3个控制点，我们首先对相邻控制点进行连线。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-13.png?x-oss-process=image/resize,w_800" /></p><p>我们定义一个变量 <code>t</code>，其值的范围为<code>[0, 1]</code>，作为算法的输入值。当 <code>t = 0</code>时，表示贝塞尔曲线起始点的输入值，当 <code>t = 1</code>时，表示贝塞尔曲线结束点的输入值。随后，我们在控制点所构成的各个连线上定义一个点，这个点的位置取决于<code>t</code> 的值，即一个比例值。比如：<spanclass="math inline">\(b_0b_1\)</span> 连线上定义点 <spanclass="math inline">\(b_{0}^{1}\)</span>，<spanclass="math inline">\(b_1b_2\)</span> 连线上定义点 <spanclass="math inline">\(b_{1}^{1}\)</span>。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-14.png?x-oss-process=image/resize,w_800" /></p><p>然后，我们继续对 <span class="math inline">\(b_{0}^{1}\)</span> 和<span class="math inline">\(b_{1}^{1}\)</span>进行连线，并按照上述规则，在 <spanclass="math inline">\(b_{0}^{1}b_{1}^{1}\)</span> 连线上定义点 <spanclass="math inline">\(b_{0}^{2}\)</span>，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-15.png?x-oss-process=image/resize,w_800" /></p><p>当新定义的点只有一个时，我们可以将 <code>t</code> 的值逐步从<code>0</code> 变到 <code>1</code>。在这个过程中，<spanclass="math inline">\(b_{0}^{1}\)</span>、<spanclass="math inline">\(b_{0}^{2}\)</span>、<spanclass="math inline">\(b_{1}^{1}\)</span> 的位置都会随着 <code>t</code>的变化而变化。对于最终的贝塞尔曲线，我们只需要关注最后定义的点 <spanclass="math inline">\(b_{0}^{2}\)</span> 的路径即可，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-16.png?x-oss-process=image/resize,w_800" /></p><p>当我们扩展至更多控制点时，比如 4个控制点时，我们仍然按照上述规则来处理，将高阶贝塞尔曲线转化为一阶贝塞尔曲线的组合，最终绘制曲线。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-17.png?x-oss-process=image/resize,w_800" /></p><h3 id="代数公式">代数公式</h3><p>对于上述通过 3个控制点绘制贝塞尔曲线，我们可以用代数的方式来表示，如下所示。</p><span class="math display">\[\begin{aligned}b_{0}^{1}(t) = &amp;(1 - t)b_0 + tb_1\\b_{1}^{1}(t) = &amp;(1 - t)b_1 + tb_2\\b_{0}^{2}(t) = &amp;(1 - t)b_{0}^{1} + tb_{1}^{1}\\b_{0}^{2}(t) = &amp;(1 - t)^2b_0 + 2t(1 - t)b_1 + t^2b_2\end{aligned}\]</span><p>由此，我们可以推导出 N 阶贝塞尔曲线的代数公式，如下所示。其中，<spanclass="math inline">\(n\)</span> 表示 N 阶贝塞尔曲线，<spanclass="math inline">\(b_j\)</span> 表示控制点，<spanclass="math inline">\(B_i^n(t)\)</span> 为伯恩斯坦多项式（BernsteinPolynomials）。</p><span class="math display">\[\begin{aligned}b^n(t) = &amp;b_{0}^{n}(t) = \sum_{j=0}^{n}b_jB_{j}^{n}(t)\\B_i^n(t) = &amp;\left(\begin{matrix}n \\i \\\end{matrix}\right)t^i(1-t)^{n-i}\end{aligned}\]</span><h3 id="曲线性质">曲线性质</h3><p>贝塞尔曲线具有以下几个特性：</p><ul><li>一定过起点和终点。</li><li>不受仿射变换影响，受投影变换影响。</li><li>凸包（Convex Hull）性质：贝塞尔曲线在所有控制点的凸包范围内。</li></ul><h2 id="分段贝塞尔曲线">分段贝塞尔曲线</h2><p>根据贝塞尔曲线绘制算法，我们可以知道，改变任意一个控制点的位置都会影响整个贝塞尔曲线。因此，当控制点比较多时，我们很难进行精准的控制和调整。于是就有了分段贝塞尔曲线，即采用多条贝塞尔曲线进行串联。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-18.png?x-oss-process=image/resize,w_800" /></p><h1 id="曲面">曲面</h1><p>曲面（Surface）在图形学中应用同样非常广泛，可以用它来表示各种三维物体。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-19.png?x-oss-process=image/resize,w_800" /></p><h2 id="贝塞尔曲面">贝塞尔曲面</h2><p>贝塞尔曲线控制点都是在同一平面内，由此进行扩展，贝塞尔曲面的控制点则是分部在三维空间中。下图所示，展示了空间中4 x 4 个控制点所构成的贝塞尔曲面。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-20.png?x-oss-process=image/resize,w_800" /></p><h3 id="绘制算法-1">绘制算法</h3><p>贝塞尔曲面的绘制算法本质上还是基于 De Casteljau's Algorithm进行多次绘制。以下图为例，首先基于预设的所有控制点（比如：4 x 4的控制点），绘制 4条贝塞尔曲线。然后在与曲线垂直的平面中开始绘制曲线，按照固定间距，以 4条贝塞尔曲线上的点作为控制点，绘制贝塞尔曲线。以此类推，最终得到一个贝塞尔曲面。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-21.png?x-oss-process=image/resize,w_800" /></p><h2 id="曲面处理">曲面处理</h2><p>根据上述绘制算法，我们可以得到基于多边形网格的曲面。在实际应用中，我们会对曲面进行进一步的处理。常见的曲面处理操作有以下两种：</p><ul><li><strong>网格细分</strong>（Mesh Subdivision）</li><li><strong>网格简化</strong>（Mesh Simplification）</li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-22.png?x-oss-process=image/resize,w_800" /></p><h3 id="网格细分">网格细分</h3><p>网格细分就是把一个多边形拆分成多个多边形。这里我们介绍两种细分算法：Loop细分和 Catmull-Clark 细分。</p><h4 id="loop-细分">Loop 细分</h4><p>Loop 细分只适用于三角形面的细分，具体可以分为两步：</p><ul><li>将一个三角形拆分成四个三角形</li><li>更新新顶点和旧顶点的位置，使模型变得更加光滑</li></ul><p>三角形的拆分非常简单，连接每条边的中点，即可将拆分成四个三角形，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-23.png?x-oss-process=image/resize,w_800" /></p><p>对于新顶点的更新，它会基于周围四个旧顶点求加权平均，离它近的顶点权重大，设为3/8，离它远的顶点权重小，设为 1/8，如下所示，白点为待更新的新顶点。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-24.png?x-oss-process=image/resize,w_800" /></p><p>对于旧顶点的更新，它会基于周围几个旧顶点求加权平均，其中各个点的权重值与待更新点的度（Degree）有关，最终可以得到如下所示的更新方法。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-25.png?x-oss-process=image/resize,w_800" /></p><p>Loop 细分只针对三角形面进行细分，整体效果如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-29.png?x-oss-process=image/resize,w_800" /></p><h4 id="catmull-clark-细分">Catmull-Clark 细分</h4><p>相比对 Loop 细分，Catmull-Clark细分是一种更加通用的细分方法，适用于各种多边形网格曲面。Catmull-Clark细分涉及到一个概念 <strong>奇异点</strong>（ExtraordinaryVertex），即度不为 4 的点。</p><p>Catmull-Clark 细分的第一步同样是拆分多边形，主要包含以下几点：</p><ul><li>对于边，取其中点；对于面，也取其一个点（比如：重心）</li><li>连接边的中点和面的中点</li></ul><p>我们可以发现，当对非四边形进行一次细分后，所有的非四边形都消失了。不过，一次细分后，会引入两个新的奇异点。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-26.png?x-oss-process=image/resize,w_800" /></p><p>对于新顶点的更新，可以分为两种情况，分别边上的点和面中的点，其规则如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-27.png?x-oss-process=image/resize,w_800" /></p><p>对于旧顶点的更新，其更新规则如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-28.png?x-oss-process=image/resize,w_800" /></p><p>Catmull-Clark 适用于任何多边形网格面，整体效果如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-30.png?x-oss-process=image/resize,w_800" /></p><h3 id="网格简化">网格简化</h3><p>网格简化与网格细分正好相反，其目的是为了减少三角形数量，从而提升性能。对于近的物体三角形多，远的物体三角形少。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-31.png?x-oss-process=image/resize,w_800" /></p><p>网格简化是通过 <strong>边坍缩</strong>（EdgeCollapse）实现的，它会减少边的数量，并更新相关顶点的位置。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-32.png?x-oss-process=image/resize,w_800" /></p><p>那么边坍缩的底层依据是什么呢？这里涉及到<strong>二次度量误差</strong>（Quadirc ErrorMetrics）的概念。二次度量误差用来表示网格简化带来的误差大小，其计算方法是新顶点与它关联的面的垂直距离的平方和，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/geometry-33.png?x-oss-process=image/resize,w_800" /></p><p>当删除一条边时，我们会引入一个新的顶点，当新顶点调整至二次度量误差最小时，我们将其设置为边坍缩后的新顶点。利用这种贪心思想，就能实现网格简化。</p><h1 id="总结">总结</h1><p>本文我们主要介绍了计算机图形学中的几何相关部分。首先，我们介绍了几何的几种表示方法：隐式几何表示和显式集合表示，两者各自又有着很多实现方法。</p><p>然后，我们介绍了曲线，特别是贝塞尔曲线，详细介绍了其绘制算法 DeCasteljau's Algorithm。由此延伸值曲面的绘制，特别是贝塞尔曲面。</p><p>最后，我们介绍了曲面的两种常见的处理方式：网格细分和网格简化。</p><p>至此，几何相关的内容均已介绍完毕。后续，我们将探讨光线追踪渲染器的相关内容。</p><h1 id="参考">参考</h1><ol type="1"><li>《GAMES 101》</li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;前面我们介绍了观测变换、光栅化、着色等几个图形学中比较复杂的主题，本文我们稍微放松一下，介绍一个相对比较简单的主题——几何。&lt;/p&gt;</summary>
    
    
    
    <category term="图形学" scheme="http://chuquan.me/categories/%E5%9B%BE%E5%BD%A2%E5%AD%A6/"/>
    
    
    <category term="几何" scheme="http://chuquan.me/tags/%E5%87%A0%E4%BD%95/"/>
    
    <category term="Loop Subdivision" scheme="http://chuquan.me/tags/Loop-Subdivision/"/>
    
    <category term="Catmull-Clark Subdivision" scheme="http://chuquan.me/tags/Catmull-Clark-Subdivision/"/>
    
    <category term="贝塞尔曲线" scheme="http://chuquan.me/tags/%E8%B4%9D%E5%A1%9E%E5%B0%94%E6%9B%B2%E7%BA%BF/"/>
    
    <category term="De Casteljau&#39;s Algorithm" scheme="http://chuquan.me/tags/De-Casteljau-s-Algorithm/"/>
    
  </entry>
  
  <entry>
    <title>计算机图形学基础（5）——着色</title>
    <link href="http://chuquan.me/2024/04/05/foundation-of-computer-graphic-05/"/>
    <id>http://chuquan.me/2024/04/05/foundation-of-computer-graphic-05/</id>
    <published>2024-04-05T07:52:33.000Z</published>
    <updated>2024-04-06T03:22:26.023Z</updated>
    
    <content type="html"><![CDATA[<p>上一篇 <ahref="http://chuquan.me/2024/03/30/foundation-of-computer-graphic-04/">文章</a>我们介绍了光栅化所涉及的基本内容。通过光栅化，我们可以实现将 3D空间模型的投影绘制到 2D屏幕。然而，仅仅实现光栅化，还不足以让渲染结果具有真实感，如下图左部所示。我们希望能够模拟光线所带来的的明暗效果，如下图右部所示。</p><span id="more"></span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-01.png?x-oss-process=image/resize,w_800" /></p><p>在计算机图形学中，<strong>着色</strong>（Shading）就是通过计算来决定三维模型表面每个像素的颜色和亮度的过程。本质而言，着色就是<strong>对不同物体应用不同材质</strong>。</p><h1 id="着色模型">着色模型</h1><h2 id="着色局部性">着色局部性</h2><p>具体分析着色时，我们会分析光线照射到物体表面的每一个点，也称<strong>着色点</strong>（ShadingPoint）。对于每个着色点，我们将其视为一个微平面（或称单位平面），由此我们可以构建法线。整体而言，着色的最终结果受以下几种输入影响，分别是：</p><ul><li>观测方向 <span class="math inline">\(v\)</span></li><li>表面法向 <span class="math inline">\(n\)</span></li><li>光线方向 <span class="math inline">\(l\)</span></li><li>表面参数，如：颜色、材质。</li></ul><blockquote><p>注意：对于着色过程，我们只考虑光照对于物体表面的影响，而不考虑其他物体的阴影对本物体产生的影响。</p></blockquote><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-03.png?x-oss-process=image/resize,w_800" /></p><h2 id="布林-冯反射模型">布林-冯反射模型</h2><p>关于光线与物体表面的作用，根据我们的认知，其实可以分为三种类型：</p><ul><li><strong>漫反射</strong>（Diffuse）</li><li><strong>高光</strong>（Specular）</li><li><strong>环境光</strong>（Ambient）</li></ul><p>在计算机图形学中，有一种广泛使用的光照和颜色计算模型——<strong>布林-冯反射模型</strong>（Blinn-PhongRelectanceModel），其考虑了上述三种光照的叠加效果对物体表面颜色的影响。</p><p>下面，我们分别来介绍这三种光照类型。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-02.png?x-oss-process=image/resize,w_800" /></p><h3 id="漫反射">漫反射</h3><p>当光线照射到一个点时，光线会向各个方向发生反射，这种现象称为<strong>漫反射</strong>。漫反射的反射光强主要受到光照角度、光照强度、漫反射系数等因素的影响。</p><h4 id="光照角度">光照角度</h4><p>在图形学中，<strong>兰伯特余弦定理</strong>（Lambert's cosinelaw）详细描述了光照角度对于表面接收光照照射量的影响。下图所示，列举了三种光照角度。</p><ul><li>情况一，入射角度为 <spanclass="math inline">\(90^{\circ}\)</span>，单位平面会接收全部光照。</li><li>情况二，入射角度为 <spanclass="math inline">\(30^{\circ}\)</span>，单位平面只会接收到一半光照。</li><li>情况三，入射角度为 <spanclass="math inline">\(90^{\circ}-\theta\)</span>，单位平面接收到的光照占全部光照的比例为<span class="math inline">\(cos{\theta} = \hat{l} \cdot\hat{n}\)</span>。</li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-04.png?x-oss-process=image/resize,w_800" /></p><p>基于兰伯特余弦定理，我们可以推导出一个函数表示单位平面接收的光照照射量占全部光照的比例，如下所示。由于<span class="math inline">\(cos\theta\)</span>可能会负数，但这没有意义，所以我们使用 <spanclass="math inline">\(max(0, cos\theta)\)</span> 来保证其值大于等于0。</p><span class="math display">\[\begin{aligned}f(\theta) = max(0, cos\theta) = max(0, \hat{l} \cdot \hat{n})\end{aligned}\]</span><h4 id="光照强度">光照强度</h4><p>对于光照强度，我们考虑如下所示 3D空间中的一个点光源。根据能量守恒定理，以光源为球心，任意距离为半径的球体，球面所覆盖的光线强度是相等的。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-34.png?x-oss-process=image/resize,w_800" /></p><p>由此，我们可以推导光照强度与光源距离之间的关系。假设半径 <spanclass="math inline">\(r\)</span> 为 <code>1</code>时，球面一个点的光照强度为 <spanclass="math inline">\(I\)</span>。那么当半径为任意值 <spanclass="math inline">\(r\)</span> 时，我们可以根据能量守恒定理得到：</p><span class="math display">\[\begin{aligned}单位球面光照强度：&amp;4{\pi}I\\任意球面光照强度：&amp;4{\pi}r^2I_r\\根据能量守恒定理：&amp;4{\pi}r^2I_r = 4{\pi}I\\任意点的光照强度：&amp;I_r=I/r^2\end{aligned}\]</span><h4 id="漫反射系数">漫反射系数</h4><p>不同的材质具有不同的漫反射系数，我们将漫反射系数定义为 <spanclass="math inline">\(k_d\)</span>。如下所示，<spanclass="math inline">\(k_d\)</span>越大，反射的光线强度越大，看到的物体越亮。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-05.png?x-oss-process=image/resize,w_800" /></p><h4 id="漫反射公式">漫反射公式</h4><p>漫反射光线的计算公式其实就是由上述三部分组成，如下所示。</p><span class="math display">\[\begin{aligned}L_d = k_d(I/r^2)max(0, \hat{l} \cdot \hat{n})\end{aligned}\]</span><h3 id="高光">高光</h3><p>高光反射，当观测向量趋近于光线的反射向量时，我们可以看到镜面反射所产生的高光，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-06.png?x-oss-process=image/resize,w_800" /></p><h4 id="高光区域">高光区域</h4><p>那么如何判断高光区域呢？我们可以通过计算光照方向向量和观测方向向量之间的<strong>半程向量</strong>（HalfVector）。然后再计算半程向量与平面法线之间的夹角，判断两者是否接近。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-07.png?x-oss-process=image/resize,w_800" /></p><p>如下所示为半程向量的计算公式，有了半程向量之后，我们可以计算法向量与半程向量之间的夹角。</p><span class="math display">\[\begin{aligned}\hat{h} = bisector(\hat{v}, \hat{l}) = \frac{\hat{v} + \hat{l}}{|\hat{v}+ \hat{l}|}\end{aligned}\]</span><h4 id="高光突变">高光突变</h4><p>根据日常经验，我们可以发现当法向量与半程向量之间的夹角大于某个阈值之后，高光效应会发生突变。如果我们使用<span class="math inline">\(cos\theta\)</span>来描述这种突变，显示是不合适的。在布林-冯模型中，我们对 <spanclass="math inline">\(cos^p\theta\)</span> 来描述高光突变，其中 <spanclass="math inline">\(p\)</span> 是一个经验值。下图所示，展示了不同<span class="math inline">\(p\)</span> 值随角度变化的曲线。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-08.png?x-oss-process=image/resize,w_800" /></p><h4 id="高光系数">高光系数</h4><p>类似于漫反射系数，对于高光，这里也有一个高光系数，使用 <spanclass="math inline">\(k_s\)</span> 表示。下图所示为不同 <spanclass="math inline">\(k_s\)</span> 和不同 <spanclass="math inline">\(p\)</span>的情况下，高光效果的对比。可以看出，高光系数越大，观测的效果越明亮。高光突变的<span class="math inline">\(p\)</span> 值越大，高光区域则越小。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-09.png?x-oss-process=image/resize,w_800" /></p><h4 id="高光公式">高光公式</h4><p>高光的计算公式其实也是由三部分组成：高光系数、光线强度、高光突变，具体公式如下所示。</p><span class="math display">\[\begin{aligned}L_s = k_s(I/r^2)max(0, \hat{n} \cdot \hat{h})^p\end{aligned}\]</span><h3 id="环境光">环境光</h3><p>在现实世界中，我们知道即使没有光源直接照射物体，物体也并不是完全是黑色的。对此，布林-冯着色模型也近似处理了这种情况，即环境光。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-10.png?x-oss-process=image/resize,w_800" /></p><h4 id="环境光公式">环境光公式</h4><p>环境光的计算公式非常简单，由环境光系数和环境光强度组成，具体公式如下所示。</p><span class="math display">\[\begin{aligned}L_a = k_aI_a\end{aligned}\]</span><h3 id="光线反射公式">光线反射公式</h3><p>布林-冯反射模型定义了一个光线反射公式，该公式由上述三种光照反射类型的计算公式组合，具体公式如下所示。</p><span class="math display">\[\begin{aligned}L = L_a + L_d + L_s = k_aI_a + k_d(I/r^2)max(0, \hat{n} \cdot \hat{l}) +k_s(I/r^2)max(0, \hat{n} \cdot \hat{h})^p\end{aligned}\]</span><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-11.png?x-oss-process=image/resize,w_800" /></p><h1 id="着色频率">着色频率</h1><p>在布林-冯反射模型中，我们以着色点（单位平面）为单位介绍三种光照反射类型。那么在真实着色过程中，以什么为单位进行着色呢？考虑到着色性能的开销，实际上可以分为三种类型，分别是：</p><ul><li><strong>平面着色</strong>（Flat Shading）</li><li><strong>顶点着色</strong>（Gouraud Shading）</li><li><strong>像素着色</strong>（冯-着色，Phong Shading）</li></ul><h2 id="平面着色">平面着色</h2><p>平面着色会对每一个平面做一次着色。相对而言，着色频率低，性能开销小，但是着色效果不够丝滑，会有明显的棱边效果。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-12.png?x-oss-process=image/resize,w_800" /></p><p>在布林-冯反射模型中，着色点的法向量是计算着色的关键变量。对于平面着色而言，我们可以通过三角形的任意两条边所构成的向量，计算叉积，即可得到法向量。</p><h2 id="顶点着色">顶点着色</h2><p>顶点着色会对三角形的三个顶点进行着色。对于三角形内部的点，则基于三个顶点的颜色，使用速度更快的插值法进行计算。相比平面着色，着色频率略高，性能开销略大，但是着色效果会好一点，会有细微的棱边效果。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-13.png?x-oss-process=image/resize,w_800" /></p><p>对于顶点着色，我们需要计算三个顶点各自的法向量。通常有两种选择：</p><ul><li>当平面属于一个规则几何体的局部表面时，可以通过规则几何体的整体出发，计算对应平面的法向量。</li><li>其他情况时，可以基于周围平面的法向量，求解平均值，计算对应平面的法向量。</li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-15.png?x-oss-process=image/resize,w_800" /></p><h2 id="像素着色">像素着色</h2><p>像素着色，也称冯-着色，它会对每一个像素进行着色。这种方式着色频率很高，性能开销很大，但是着色效果非常丝滑。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-14.png?x-oss-process=image/resize,w_800" /></p><p>对于像素着色，我们首先以上述方式计算三角形顶点的法向量，对于三角形内部的点，则通过<strong>重心插值法</strong>（BarycentricInterpolation）来计算。关于重心插值法，我们稍后进行介绍。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-16.png?x-oss-process=image/resize,w_800" /></p><h1 id="实时渲染管线">实时渲染管线</h1><p>实时渲染管线（Real-time Rendering），也称图形管线（GraphicsPipeline），其描述了 3D 场景转换成 2D 图像的完整流程，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-17.png?x-oss-process=image/resize,w_800" /></p><p>实时渲染管线可以分为五个阶段，分别是：</p><ul><li><strong>顶点处理</strong>（Vertex Processing）</li><li><strong>三角形处理</strong>（Triangle Processing）</li><li><strong>光栅化</strong>（Rasterization）</li><li><strong>片段处理</strong>（Fragment Processing）</li><li><strong>帧缓冲操作</strong>（Framebuffer Operations）</li></ul><h2 id="顶点处理">顶点处理</h2><p>顶点处理的输入是 3D 空间中的顶点。为什么是顶点而不是 3D模型？这是因为 3D空间的所有模型都是以三角形为基本单元进行表示的，而三角形则可以通过顶点和连线来描述，3D模型的本质就是大量顶点和连线的定义。</p><p>在顶点处理阶段，我们会对顶点进行观测变换，即 <ahref="http://chuquan.me/2024/03/23/foundation-of-computer-graphic-03/">《计算机图形学基础（3）——观测变换》</a>中所介绍的 MVP 变换。最终输出经过观测变换的顶点。</p><h2 id="三角形处理">三角形处理</h2><p>在某些文章中，会将这个阶段定义成 <strong>图元处理</strong>（PrimitiveProcessing），三角形处理只是其中的一个子集，它还会处理点和线。这里我们为了突出重点，将其称为三角形处理。</p><p>由于顶点处理阶段只对顶点进行变换，而 3D模型还包括连线的定义，三角形处理阶段就是根据连线的定义，将顶点装配成三角形（也称图元）。</p><h2 id="光栅化">光栅化</h2><p>当顶点处理和三角形处理完成之后，我们得到了经过观测变换后的三角形。此时三角形仍然处于3D 空间中，不过我们可以通过正交投影快速获取它们在 2D 空间中的投影。</p><p>光栅化则是将连续的 2D 投影进行采样，转换成离散的 2D投影，这是因为屏幕由一个离散的二维像素矩阵所构成。关于光栅化具体要做的事情以及可能遇到的问题，我们在<ahref="http://chuquan.me/2024/03/30/foundation-of-computer-graphic-04/">《计算机图形学（4）——光栅化》</a>中进行了详细的介绍。</p><p>在实际的 GPU设计中，为了支持可编程、并行计算，实时渲染管线中的光栅化的主要任务是对连续的图形进行采样，使其离散化。</p><h2 id="片段处理">片段处理</h2><p>片段处理，也称像素处理，它会对每个片段的颜色、纹理坐标、深度值等进行计算，期间会大量应用插值法进行计算。严格意义上说，片段处理也属于光栅化的一部分。</p><h2 id="帧缓冲操作">帧缓冲操作</h2><p>帧缓冲操作包含了颜色混合、模板测试、深度测试、透明度检查等一系列操作，最终结果会保存在帧缓冲区，显示器会定时读取帧缓冲区，并将内容呈现在屏幕上。</p><h2 id="关于着色">关于着色</h2><p>整体而言，实时渲染管线包含观测变换、光栅化、着色三大部分。</p><p>然而，着色其实在顶点处理和片段处理阶段都可以存在，这取决于着色频率。如果我们采用顶点着色，那么着色可以发生在顶点处理阶段；如果我们采用像素着色，那么着色可以发生在片段处理阶段。</p><p>在现代 GPU中，实时渲染管线的部分阶段是支持可编程的，比如顶点处理阶段和片段处理阶段。在这些可编程阶段中，我们可以编写着色器（Shader）程序，从而生成自定义的着色结果。</p><h3 id="着色器">着色器</h3><p>在实时渲染领域，大部分从业者做的事情就是在写各种各样的着色器。如下所示，是OpenGL 中的一个片段着色器程序，其采用 GLSL着色语言编写。着色器程序最终由 GPU调用，对于每个像素都会执行并生成着色结果。</p><figure class="highlight c++"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">uniform sampler2D myTexture;    <span class="comment">// program parameter</span></span><br><span class="line">uniform vec3 lightDir;          <span class="comment">// program parameter</span></span><br><span class="line">varying vec2 uv;                <span class="comment">// per fragment value (interp. by rasterizer)</span></span><br><span class="line">varying vec3 norm;              <span class="comment">// per fragment value (interp. by rasterizer)</span></span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">void</span> <span class="title">diffuseShader</span><span class="params">()</span> </span>&#123;</span><br><span class="line">    vec3 kd;                                </span><br><span class="line">    kd = <span class="built_in">texture2d</span>(myTexture, uv);                  <span class="comment">// material color from texture</span></span><br><span class="line">    kd *= <span class="built_in">clamp</span>(<span class="built_in">dot</span>(–lightDir, norm), <span class="number">0.0</span>, <span class="number">1.0</span>);    <span class="comment">// Lambertian shading model</span></span><br><span class="line">    gl_FragColor = <span class="built_in">vec4</span>(kd, <span class="number">1.0</span>);                   <span class="comment">// output fragment color</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h1 id="纹理">纹理</h1><p>在介绍着色模型中，我们提到着色点的材质会影响最终的着色结果，比如各种反射系数<span class="math inline">\(k_d\)</span>、<spanclass="math inline">\(k_s\)</span>、<spanclass="math inline">\(k_a\)</span>等。除此之外，着色点的原始颜色、法线等属性也都会影响着色结果。</p><p>为了能够为着色点定义属性，提出了<strong>纹理</strong>（Texture）的概念，使用纹理来记录每个着色点的各种属性。通常情况下，我们会把纹理等同于贴图（图片），这是因为大多数情况下会使用纹理来定义颜色。不过从严格意义上说，贴图只是纹理的一种而已。</p><h2 id="纹理映射">纹理映射</h2><p>纹理映射的本质就是将纹理定义的属性映射到 3D 模型的各个着色点。</p><p>如下图所示，我们定义了一个模型和一个纹理，中间的模型经过纹理映射后渲染得到了我们期望的效果。在建模时，我们会将模型分割成一个个三角形。与模型所绑定的纹理，我们也会将其分割成一个个三角形。两者之间的三角形会一一对应。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-18.png?x-oss-process=image/resize,w_800" /></p><p>为了方便映射，我们会建立 <strong>纹理坐标系</strong>（TextureCoordinate），横坐标用 <span class="math inline">\(u\)</span>表示，纵坐标用 <span class="math inline">\(v\)</span> 表示。<spanclass="math inline">\(u\)</span> 和 <spanclass="math inline">\(v\)</span> 的值都在 <code>[0, 1]</code>之间，这是一个约定俗成的规定。模型中的每个顶点都会设定一个纹理坐标，通过这种方式可以实现纹理映射。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-19.png?x-oss-process=image/resize,w_800" /></p><h2 id="重心坐标">重心坐标</h2><p>虽然模型和纹理是绑定的，但是绑定是基于顶点实现的。因此在纹理映射中，对于模型三角形的顶点，我们可以直接使用绑定的纹理坐标找到纹理中对应坐标的属性。但是模型三角形内部的点该如何获取纹理属性呢？为了解决这个问题，提出了<strong>重心坐标</strong>（Barycentric Coordinate）的概念。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-20.png?x-oss-process=image/resize,w_800" /></p><p>以上图中的三角形为例，重心坐标定义了三角形内部任意一个点 <spanclass="math inline">\((x, y)\)</span> 具有以下几个特性。</p><span class="math display">\[\begin{aligned}\begin{cases}(x, y) = {\alpha}A + {\beta}B + {\gamma}C\\\alpha + \beta + \gamma = 1\\\alpha &gt;= 0;\beta &gt;= 0;\gamma &gt;= 0;\end{cases}\end{aligned}\]</span><p>最终，我们可以计算得到三角形内任意一个点的重心坐标 <spanclass="math inline">\((\alpha, \beta,\gamma)\)</span>。此时，我们可以使用重心坐标，结合顶点属性，计算得到该点的属性。这里的属性可以是位置、纹理坐标、颜色、法线、深度、材质等各种属性。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-21.png?x-oss-process=image/resize,w_800" /></p><p>需要注意的是，在投影时三角形的形状会发生变化，所以在着色时应该基于三维空间的坐标计算重心坐标，然后再做插值。</p><h2 id="纹理查询">纹理查询</h2><p>上面我们介绍了使用重心坐标表示三角形中的任意点。那么具体该如何应用重心坐标来查找对应的纹理属性呢？如下所示，我们使用伪代码描述了这个查找过程。</p><figure class="highlight c++"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">for</span> each rasterized screen <span class="title">sample</span><span class="params">(x, y)</span> </span>&#123;</span><br><span class="line">    (u, v) = evaluate texture coordinate <span class="built_in">at</span> (x, y)</span><br><span class="line">    texcolor = texture.<span class="built_in">sample</span>(u, v)</span><br><span class="line">    set sample<span class="number">&#x27;</span>s color to texcolor</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>我们先说明一个前提：在光栅化阶段，即当三角形被转换为屏幕上的像素时，每个像素的纹理坐标会通过插值方式在三角形的顶点之间计算出来。此时，我们得到的是每个像素的屏幕坐标以及对应的纹理坐标。</p><p>上述伪代码所描述的流程是：</p><ul><li>遍历光栅化得到的屏幕采样点，比如一个三角形 <spanclass="math inline">\(ABC\)</span> 的区域内的某个像素点 <spanclass="math inline">\((x, y)\)</span>。</li><li>基于上述前提，有了像素的屏幕坐标 <span class="math inline">\((x,y)\)</span>，我们可以直接获取对应的纹理坐标。</li><li>当得到像素点的纹理坐标后，我们就可以在纹理中查找对应的属性，伪代码中查找的是颜色属性。</li><li>最后我们用纹理颜色来给像素着色。</li></ul><p>本质上，这是一个纹理采样过程。一旦涉及采样，就可能会出现走样问题。下面，我们来分情况讨论。</p><h3 id="纹理太小问题">纹理太小问题</h3><p>对于纹理太小的情况，那么会出现多个像素映射到一个<strong>纹素</strong>（Texel），即纹理中的一个点或像素。此时，就会出现锯齿问题。</p><p>为了解决锯齿问题，我们可以通过求均值的方式来解决。如下所示，为最近采样、双线性插值、双三次插值的对比结果。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-22.png?x-oss-process=image/resize,w_800" /></p><p>双线性插值的原理非常简单，就是去临近的 4个像素，通过三次插值计算得到一个颜色平均值。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-23.png?x-oss-process=image/resize,w_800" /></p><p>双三次插值的原理与双线性插值类似，区别在于前者使用周围的 16个像素求插值，后者使用周围的 4 个像素求插值。</p><h3 id="纹理太大问题">纹理太大问题</h3><p>对于纹理太大的情况，会出现摩尔纹、锯齿等情况。本质上是采样频率低于信号频率，我们在<ahref="http://chuquan.me/2024/03/30/foundation-of-computer-graphic-04/">计算机图形学基础（4）——光栅化</a>中介绍过两种解决思路，一种是超采样，一种是过滤高频信号。</p><p>这两种思路，在这种场景下都存在开销过大的问题。于是，在图形学中提出了范围查询的方法，即<strong>Mipmap</strong>，从而避开了采样所带来的问题。</p><h4 id="点查询-范围查询">点查询 &amp; 范围查询</h4><p>本质上，采样就是点查询。当纹理太大时，屏幕上一个点对应到纹理上可能是一个很大的区域。然而，从这个区域中取一个点来代表整个区域的颜色，这显然是不合适的。对比而言，范围查询相当于提前计算出一个合适的值来代表这个区域。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-24.png?x-oss-process=image/resize,w_800" /></p><h4 id="mipmap">Mipmap</h4><p>Mipmap正是范围查询的一种实现方案，它会为一张纹理生成多个不同层级的纹理，如下图所示。Mipmap虽然生成了多个不同层级的纹理，但是整体的存储量只增加了不到 1/3。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-25.png?x-oss-process=image/resize,w_800" /></p><p>既然 Mipmap生成了多个不同层级的纹理，那么在纹理查询时，我们应该查询哪个层级的纹理呢？</p><p>如下图所示，对于屏幕上的一个像素点，考虑其相邻的两个点，获取它们的纹理坐标。根据纹理坐标计算相邻的距离，由此近似得到像素对应的矩形区域。我们获取矩形区域较大的边长<span class="math inline">\(L\)</span>。然后对 <spanclass="math inline">\(L\)</span>求对数，即可计算得出要查询的纹理的层级。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-26.png?x-oss-process=image/resize,w_800" /></p><span class="math display">\[\begin{aligned}L = &amp;max(\sqrt{(\frac{du}{dx})^2 + (\frac{du}{dx})^2},\sqrt{(\frac{du}{dy})^2 + (\frac{du}{dy})^2})\\D = &amp;log_2L\end{aligned}\]</span><h4 id="各向异性过滤">各向异性过滤</h4><p>事实上，Mipmap也并不是万能的。在有些场景下，也会出现过度模糊的问题，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-27.png?x-oss-process=image/resize,w_800" /></p><p>根本原因是，Mipmap的范围查询所覆盖的区域是正方形。如果屏幕像素点代表了纹理中的一个长方形区域，那么范围查询就无法准确代表长方形区域内的值，因此会出现走样，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-28.png?x-oss-process=image/resize,w_800" /></p><p>那么如何解决呢？方法是各项异性过滤（AnisotropicFiltering）。具体的技术是：除了生成针对正方形区域的范围查询的纹理外，还要生成其他形状（比如长方形）的范围查询的纹理。通过这种方式，纹理的存储量会增加3 倍，不过能够降低着色走样的概率。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-29.png?x-oss-process=image/resize,w_800" /></p><h2 id="纹理应用">纹理应用</h2><p>至此，我们基本了解了纹理及其工作原理，本质而言，纹理 = 内存存储 +范围查询。上述内容我们主要介绍了通过纹理记录颜色，事实上纹理还能记录其他很多属性，比如：环境光、微几何、法向量、高度偏移等等。</p><p>下面，我们来介绍纹理的其他几种应用。</p><h3 id="环境贴图">环境贴图</h3><p>纹理应用最多的就是 <strong>环境贴图</strong>（EnvironmentMap），这里又有非常多的类型。</p><p><strong>立方体环境贴图</strong>（Cube EnvironmentMap），它是将环境映射到一个立方体的六个面上，可以用于实现镜面反射和环境光照。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-30.png?x-oss-process=image/resize,w_800" /></p><p><strong>光照环境贴图</strong>（Light EnvironmentMap），它在渲染过程中预先计算和存储环境光照信息，以提高实时渲染效率和质量的技术</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-31.png?x-oss-process=image/resize,w_800" /></p><p>除此之外，还有很多环境贴图，比如：球谐环境贴图、镜面反射环境贴图、辐射度环境贴图、天空盒环境贴图等等。</p><h3 id="凹凸贴图">凹凸贴图</h3><p>假如我们希望渲染一个表面凹凸不同的球状体，如果使用三角形来表示，那么需要大量三角形，而且结构非常复杂。对于这种情况，我们可以凹凸贴图（BumpMap），它可以定义点的相对高度，从而改变法线，进而影响着色结果，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-32.png?x-oss-process=image/resize,w_800" /></p><h3 id="位移贴图">位移贴图</h3><p>凹凸贴图改变了着色时所使用的法向量，但并没有真正改变模型的形状。一种更现代化的<strong>位移贴图</strong>（DisplacementMapping），则定义了顶点高度的偏移量，使得真真正改变了模型的形状，从而实现更加逼真的效果。下图所示，为凹凸贴图和位移贴图的对比效果。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/shading-33.png?x-oss-process=image/resize,w_800" /></p><h1 id="总结">总结</h1><p>本文我们主要介绍了着色相关的内容。</p><p>首先，我们介绍了着色模型，具体介绍了经典的布林-冯反射模型，其由漫反射、高光、环境光三部分组成。</p><p>其次，我们介绍了几种着色频率，包括平面着色、顶点着色、像素着色，简单对比了它们之间的差异。</p><p>然后，我们简单介绍了实时渲染管线的 5个阶段，包括顶点处理、三角形处理、光栅化、片段处理、帧缓冲操作等。</p><p>最后，我们详细介绍了着色中最重要的一部分——纹理。纹理查询是是如何通过重心坐标、纹理坐标查找对应的纹理属性。当然，纹理查询也属于采样，其中也会遇到走样的问题。于是，我们引入了线性插值、Mipmap、各向异性过滤等解决方案。除此之外，我们还介绍了纹理的几种应用，包括：环境贴图、凹凸贴图、位移贴图等。</p><h1 id="参考">参考</h1><ol type="1"><li>《GAMES 101》</li><li><a href="http://shadertop.com">Shadertoy</a></li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;上一篇 &lt;a
href=&quot;http://chuquan.me/2024/03/30/foundation-of-computer-graphic-04/&quot;&gt;文章&lt;/a&gt;
我们介绍了光栅化所涉及的基本内容。通过光栅化，我们可以实现将 3D
空间模型的投影绘制到 2D
屏幕。然而，仅仅实现光栅化，还不足以让渲染结果具有真实感，如下图左部所示。我们希望能够模拟光线所带来的的明暗效果，如下图右部所示。&lt;/p&gt;</summary>
    
    
    
    <category term="图形学" scheme="http://chuquan.me/categories/%E5%9B%BE%E5%BD%A2%E5%AD%A6/"/>
    
    
    <category term="Mipmap" scheme="http://chuquan.me/tags/Mipmap/"/>
    
    <category term="各向异性过滤" scheme="http://chuquan.me/tags/%E5%90%84%E5%90%91%E5%BC%82%E6%80%A7%E8%BF%87%E6%BB%A4/"/>
    
    <category term="纹理" scheme="http://chuquan.me/tags/%E7%BA%B9%E7%90%86/"/>
    
    <category term="重心坐标" scheme="http://chuquan.me/tags/%E9%87%8D%E5%BF%83%E5%9D%90%E6%A0%87/"/>
    
    <category term="纹理坐标" scheme="http://chuquan.me/tags/%E7%BA%B9%E7%90%86%E5%9D%90%E6%A0%87/"/>
    
    <category term="布林-冯模型" scheme="http://chuquan.me/tags/%E5%B8%83%E6%9E%97-%E5%86%AF%E6%A8%A1%E5%9E%8B/"/>
    
  </entry>
  
  <entry>
    <title>计算机图形学基础（4）——光栅化</title>
    <link href="http://chuquan.me/2024/03/30/foundation-of-computer-graphic-04/"/>
    <id>http://chuquan.me/2024/03/30/foundation-of-computer-graphic-04/</id>
    <published>2024-03-30T01:20:55.000Z</published>
    <updated>2024-03-30T01:39:10.562Z</updated>
    
    <content type="html"><![CDATA[<p>上一篇 <ahref="http://chuquan.me/2024/03/23/foundation-of-computer-graphic-03/">文章</a>我们介绍了矩阵变换在计算机图形学中的应用，包括：视图变换、模型变换、投影变换。此外，我们还详细介绍了投影变换中的正交投影和透视投影，以及屏幕映射过程中的视口变换。</p><span id="more"></span><p>本文，我们来介绍一下计算机图形学中最重要的内容之一——光栅化。</p><h1 id="栅格显示">栅格显示</h1><p>光栅化（Rasterization）中光栅（Raster）一词来源于德语，表示栅格的意思。我们现在用的显示设备基本上都是由像素点阵构成的栅格显示设备。因此，我们很容易理解光栅化的含义，即在栅格显示设备上绘制图形。</p><p>这里我们先介绍一下常见的栅格显示设备。</p><p>旧式的阴极射线管（Cathode Ray Tube，CRT）电视，它的基本原理是<strong>通过射线管将电子射到屏幕进行逐行扫描</strong>，如下图所示。在实际应用中，会借助视觉暂留效应，对屏幕进行<strong>隔行扫描</strong>，从而降低扫描的计算量。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rasterization-01.png?x-oss-process=image/resize,w_800" /></p><p>现代平板显示器（Flat Panel Displays）中最常用的是液晶显示器（LiquidCrystal Display，LCD），它的基本原理是<strong>通过扭转偏振来阻挡或传输光线</strong>，如下图所示。在实际应用中，会使用<strong>帧缓冲</strong>（FrameBuffer）来提前缓存画面的帧数据，从而提高显示流畅度。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rasterization-02.png?x-oss-process=image/resize,w_800" /></p><h1 id="光栅化">光栅化</h1><h2 id="基本单元">基本单元</h2><p>光栅化的基本单元是三角形，采用三角形作为基本单元的原因是：</p><ul><li>三角形是最基本的多边形。</li><li>三角形具有平面性。</li><li>三角形可以明确定义内部和外部。我们可以通过向量叉积来判断，详见 <ahref="http://chuquan.me/2024/01/15/foundation-of-computer-graphic-01/">计算机图形学基础（1）——线性代数</a>。</li><li>任意多边形可以拆分成 N 个三角形。</li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rasterization-07.png?x-oss-process=image/resize,w_800" /></p><h2 id="采样绘制">采样绘制</h2><p>2D屏幕是一个离散的像素阵列，空间中的三角形则是一个连续的函数。采样绘制的本质则是对一个函数进行离散化。具体的做法是：</p><ul><li>遍历像素阵列，判断每一个像素阵列是否位于三角形的投射区域内</li><li>如果是，进行绘制像素；否则，不绘制。</li></ul><p>如下所示为采样绘制的伪代码和示意图。注意，像素本身是一个矩形区域，因此判断像素是否在三角形内部时，采用的是像素点的中心作为参照。<figure class="highlight c++"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="type">bool</span> <span class="title">inside</span><span class="params">(t, x, y)</span> </span>&#123;</span><br><span class="line">    <span class="function"><span class="keyword">if</span> <span class="title">point</span><span class="params">(x, y)</span> in <span class="title">triangle</span><span class="params">(t)</span> </span>&#123;</span><br><span class="line">        <span class="keyword">return</span> <span class="number">1</span></span><br><span class="line">    &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> <span class="number">0</span></span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">for</span> (<span class="type">int</span> x = <span class="number">0</span>; x &lt; xmax; ++x) &#123;</span><br><span class="line">    <span class="keyword">for</span> (<span class="type">int</span> y = <span class="number">0</span>; y &lt; ymax; ++y) &#123;</span><br><span class="line">        image[x][y] = <span class="built_in">inside</span>(tri, x + <span class="number">0.5</span>, y + <span class="number">0.5</span>);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rasterization-03.png?x-oss-process=image/resize,w_800" /></p><p>在绘制三角形时，一般不会对整个屏幕的像素点进行扫描，而是仅仅对三角形的<strong>包围盒</strong>（BoundingBox）区域内的像素进行扫描和绘制，从而有效降低算法复杂度。对于一些窄长三角形，甚至可以进一步优化算法，如下图右侧所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rasterization-04.png?x-oss-process=image/resize,w_800" /></p><h2 id="核心问题">核心问题</h2><p>我们观察上述这种简单的采样绘制方式，可以发现一个很明显的问题——<strong>锯齿</strong>（Jaggies）。这个问题根本上是采样导致的，对于这种现象我们称之为<strong>走样</strong>（Aliasing）。走样会带来很多奇怪的现象，比如：锯齿、摩尔纹（MoirePatterns）、车轮效应等，如下图所示。</p><p>光栅化要解决的核心问题就是走样问题，即<strong>反走样</strong>（Antialiasing）。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rasterization-05.png?x-oss-process=image/resize,w_800" /></p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rasterization-06.png?x-oss-process=image/resize,w_800" /></p><h2 id="解决方法">解决方法</h2><p>计算机图形学中解决走样问题的最常用方法是：<strong>先模糊，后采样</strong>。模糊，从字面上理解就是将图片虚化，从数学上理解则是<strong>滤波</strong>，关于滤波，我们将在下一节中进行介绍。</p><p>下图所示，为「直接采样」和「先模糊，后采样」的流程对比图。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rasterization-08.png?x-oss-process=image/resize,w_800" /></p><p>在具体实践中，通过这种方式能够有效解决光栅化中的锯齿问题，如下所示为反走样前后的效果对比图。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rasterization-09.png?x-oss-process=image/resize,w_800" /></p><p>这里，我们可能会产生疑问：</p><ul><li>出现走样的根本原因是什么？</li><li>为什么先模糊（滤波）后采样能够实现反走样？</li></ul><p>要讲明白这些内容，我们必须要介绍一下采样理论。</p><h1 id="采样理论">采样理论</h1><p>采样理论是信号系统中非常重要的一个理论，它在数字信号处理、数字通信、图像处理等众多领域都有着广泛的应用。</p><p>在实际应用中，我们通过一定的采样率把连续信号转换为离散信号，然后再对离散信号进行处理。处理完后，我们又可以通过一定的重构方法把离散信号转换回连续信号，以便在实际系统中使用。</p><h2 id="傅里叶级数">傅里叶级数</h2><p>那么如何表示任意一种信号呢？法国数学家傅里叶认为，任何周期函数（信号）都可以用正弦函数和余弦函数构成的无穷级数来表示，如下所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rasterization-10.png?x-oss-process=image/resize,w_800" /></p><p>对于上图中的信号，使用傅里叶级数展开的表示如下所示。其中，这里 <spanclass="math inline">\(t\)</span> 表示时间，<spanclass="math inline">\(A\)</span> 表示振幅，<spanclass="math inline">\(w\)</span> 表示角频率。</p><span class="math display">\[\begin{aligned}f(x) = \frac{2Acos(tw)}{\pi} - \frac{2Acos(3tw)}{3\pi} +\frac{2Acos(5tw)}{5\pi} - \frac{2Acos(7tw)}{7\pi} + ...\end{aligned}\]</span><h2 id="时域与频域">时域与频域</h2><p>基于傅里叶级数，我们可以对信号的时域（以时间为横坐标）和频域（以频率为横坐标）进行相互转换：</p><ul><li>时域转换成频域：采用 <strong>傅里叶变换</strong>（FourierTransform）</li><li>频域转换成时域：采用 <strong>逆傅里叶变换</strong>（Inverse FourierTransform）</li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rasterization-11.png?x-oss-process=image/resize,w_800" /></p><h2 id="走样原理">走样原理</h2><p>了解了信号的时域和频域之后，我们再来介绍走样的原理。</p><p>理想情况下，对一个连续信号进行采样后得到的离散信号，应该能够近似重构原始信号。然而，当采样频率低于原始信号的频率时，就会很容易出现走样的问题。换句话说，就是采样得到的离散信号无法近似重构原始信号。</p><p>下图所示，我们列举了几种信号，信号频率依次从高到低，我们使用相同的频率对这些信号进行采样。很显然，我们对低频信号进行采样时，由于采样频率大于信号频率，得到的离散信号可以近似重构原始信号；但是，我们对高频信号采样时，由于采样频率小于信号频率，得到的离散信号则无法近似重构原始信号。</p><p>因此走样的根本原因就是 <strong>采样频率小于信号频率</strong>。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rasterization-12.png?x-oss-process=image/resize,w_800" /></p><h1 id="滤波">滤波</h1><p>由于滤波在反走样中起到了重要作用，因此我们简单介绍一下图像处理中滤波。</p><p>如下图所示，通过傅里叶变换将左侧的像素空间（空间域）变为右侧的频谱（频域）。对于二维信号，其频谱的表示如下：</p><ul><li>高频部分代表细节、边缘、噪声</li><li>低频占据绝大多数能量，其中直流分量（零频）能量占比最大</li><li>频率分部具有中心对称性</li></ul><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rasterization-13.png?x-oss-process=image/resize,w_800" /></p><p>下面，我们来介绍一下几种常见的滤波。</p><h2 id="高通滤波">高通滤波</h2><p>高通滤波（High-passfilter），保留高频信号。在图像中，轮廓的边缘会发生剧烈变化，属于高频信号。经过高通滤波后，图像只会保留一些轮廓信息，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rasterization-14.png?x-oss-process=image/resize,w_800" /></p><h2 id="低通滤波">低通滤波</h2><p>低通滤波（Low-passfilter），保留低频信号。在图像中，颜色变化平缓的区域属于低频信号。经过低通滤波后，图像会抹去轮廓信息，如下图所示。模糊处理基于低通滤波实现的。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rasterization-15.png?x-oss-process=image/resize,w_800" /></p><h2 id="带通滤波">带通滤波</h2><p>带通滤波（Band-passfilter），顾名思义，只保留一部分频率范围内的信号。对图像滤波后的效果取决于带通滤波所选择的频率范围。下图所示，为两种不同频率范围的带通滤波。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rasterization-16.png?x-oss-process=image/resize,w_800" /></p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rasterization-17.png?x-oss-process=image/resize,w_800" /></p><h2 id="卷积">卷积</h2><p>那么如何实现滤波呢？卷积（Convolution）就是实现滤波的主要数学工具和底层原理。滤波器的基本原理是<strong>响应函数与输入信号进行卷积运算</strong>，因此滤波器也可以称为<strong>卷积核</strong>。</p><p>如下所示，是卷积的数学定义，两个函数的 <spanclass="math inline">\(f\)</span> 和 <spanclass="math inline">\(g\)</span> 卷积 <span class="math inline">\(f *g(n)\)</span>。</p><span class="math display">\[\begin{aligned}连续形式：&amp;(f * g)(n) = \int_{-\infty}^{\infty}f(\tau)g(n-\tau)d\tau\\离散形式：&amp;(f * g)(n) = \sum_{-\infty}^{\infty}f(\tau)g(n-\tau)\end{aligned}\]</span><p>观察 <span class="math inline">\(f(\tau)\)</span> 和 <spanclass="math inline">\(g(n-\tau)\)</span> 的关系，可以发现是对<code>g</code> 函数进行了「翻转」，这就是「卷」的来源。同时，对两个函数<code>f</code> 和 <code>g</code> 进行积分，这就是「积」的来源。</p><p>卷积本身是一个很难解释的数学定义，如果你想深入理解卷积，这里推荐一篇知乎高赞回答——<ahref="https://www.zhihu.com/question/22298352/answer/228543288">传送门</a>。简而言之，<strong>两个函数的卷积，会先将一个函数翻转，然后进行滑动叠加</strong>。本质上可以将卷积理解成加权平均。</p><p>下图所示，是对图像进行滤波（卷积）的过程，实现模糊处理。基于傅里叶变换，我们可以实现时域（空间域）与频域之间的相互转换。<strong>时域（空间域）上对两个信号进行卷积，等同于频域上对两个信号的频率进行乘积</strong>。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rasterization-18.png?x-oss-process=image/resize,w_800" /></p><h1 id="反走样原理">反走样原理</h1><p>在「走样原理」一节中，我们提到了走样的根本原因是<strong>采样频率小于信号频率</strong>。在不提高采样频率的前提下，通过<strong>先滤波，后采样</strong>的方式可以实现反走样，这里的底层逻辑又是什么呢？</p><p>简单的理解就是，<strong>滤波（低通滤波，即模糊处理）会过滤掉信号中大于采样频率的信号分量</strong>。滤波后，剩余的信号分量的频率满足<strong>采样频率 &gt;= 信号频率</strong> 的条件，因此实现了反走样。</p><p>实现反走样的方法主要就是围绕两个角度来实现：</p><ul><li>提高采样频率。如：超采样技术（Supersampling）、多重采样抗锯齿（MSAA）、超分辨率</li><li>过滤高频信号。如：先模糊后采样（Pre-Filter）</li></ul><h1 id="遮挡与可见">遮挡与可见</h1><p>上述内容介绍了光栅化一个三角形的场景，以及其会遇到的问题——走样。下面，我们来介绍光栅化多个三角形会遇到的问题——遮挡与可见问题。</p><p>在 3D空间中，三角形之间存在着前后遮挡关系，那么三角形绘制的先后顺序应该是什么样的呢？</p><h2 id="画家算法">画家算法</h2><p>对此，我们先介绍一个经常被提到的算法：<strong>画家算法</strong>（Painter'sAlgorithm）。</p><p>画家算法，顾名思义，按照画家绘画时的先后顺序来执行，远的物体先绘制，进的物体后绘制，如下图所示。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rasterization-22.png?x-oss-process=image/resize,w_800" /></p><p>虽然画家算法适用于绝大多数场景，但是在某些场景下它仍然无法解决可见性问题。如下图所示，三个相互嵌套的三角形，使用画家算法则无法对三角形进行排序，因此无法准确实现光栅化。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rasterization-23.png?x-oss-process=image/resize,w_800" /></p><h2 id="深度缓冲算法">深度缓冲算法</h2><p>那么上述问题该如何解决呢？于是出现了<strong>深度缓冲算法</strong>（Z-Buffer Algorithm），其基本原理是：</p><ul><li>光栅化采用两个缓冲区<ul><li>原有的 <strong>帧缓冲区</strong>（FrameBuffer）存储每个像素颜色值</li><li>附加的<strong>深度缓冲区</strong>（Z-Buffer）存储每个像素深度值</li></ul></li><li>深度缓冲区存储每个像素当前的<strong>最小深度值</strong>（Z-Value）</li></ul><p>如下所示，为深度缓冲算法的伪代码实现。注意：我们会初始化深度值为无穷大。</p><figure class="highlight c++"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">for</span> (each triangle T) &#123;</span><br><span class="line">    <span class="keyword">for</span> (each <span class="built_in">sample</span> (x, y, z) in T) &#123;</span><br><span class="line">        <span class="keyword">if</span> (z &lt; zbuffer[x, y]) &#123;            <span class="comment">// 处理深度更小的采样点</span></span><br><span class="line">            framebuffer[x, y] = rgb;        <span class="comment">// 更新颜色值</span></span><br><span class="line">            zbuffer[x, y] = z;              <span class="comment">// 更新深度值</span></span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>下图所示，使用深度缓冲算法光栅化两个三角形的示意图。当光栅化红色三角形时，我们遍历红色三角形的每一个像素的深度值，并与当前深度值进行比较。由于当前深度值均为无穷大，所以红色三角形的每一个像素都可以绘制。当光栅化蓝色三角形时，同样会遍历蓝色三角形每一个像素的深度值，并与当前深度值变换，深度值大于当前深度值，则不绘制；否则，绘制并更新当前深度值。</p><p><imgsrc="https://chuquan-public-r-001.oss-cn-shanghai.aliyuncs.com/sketch-images/rasterization-24.png?x-oss-process=image/resize,w_800" /></p><p>注意，这里的深度值比较取决于坐标系是如何建立的。按照我们之前的介绍，相机是沿着-Z 轴方向观测，因此深度越大，则 Z 值越小。</p><h1 id="总结">总结</h1><p>本文，我们主要介绍了光栅化技术。首先介绍了光栅化的含义以及栅格设备。其次，我们介绍了光栅化的基本单元——三角形。</p><p>在绘制单个三角形时，我们会遇到走样问题。对此我们介绍了反走样的两种方法：提高采样频率、过滤信号频率。我们着重介绍了后者，先滤波（模糊）后采样，并介绍了其中涉及的原理。</p><p>在绘制多个三角形时，我们会遇到遮挡问题。对此我们介绍了两种算法：画家算法、深度缓冲算法。</p><p>后续，我们还会继续介绍计算机图形学的其他内容，敬请期待吧~</p><h1 id="参考">参考</h1><ol type="1"><li>《GAMES 101》</li><li><ahref="https://www.zhihu.com/question/22298352/answer/228543288">如何通俗易懂地解释卷积</a></li><li><ahref="https://www.zhihu.com/tardis/zm/art/54946461?source_id=1003">傅里叶变换与图像的频域处理</a></li><li><ahref="https://dezeming.top/wp-content/uploads/2022/04/%E9%87%87%E6%A0%B7%E5%AE%9A%E7%90%86.pdf">采样定理</a></li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;上一篇 &lt;a
href=&quot;http://chuquan.me/2024/03/23/foundation-of-computer-graphic-03/&quot;&gt;文章&lt;/a&gt;
我们介绍了矩阵变换在计算机图形学中的应用，包括：视图变换、模型变换、投影变换。此外，我们还详细介绍了投影变换中的正交投影和透视投影，以及屏幕映射过程中的视口变换。&lt;/p&gt;</summary>
    
    
    
    <category term="图形学" scheme="http://chuquan.me/categories/%E5%9B%BE%E5%BD%A2%E5%AD%A6/"/>
    
    
    <category term="画家算法" scheme="http://chuquan.me/tags/%E7%94%BB%E5%AE%B6%E7%AE%97%E6%B3%95/"/>
    
    <category term="深度缓冲算法" scheme="http://chuquan.me/tags/%E6%B7%B1%E5%BA%A6%E7%BC%93%E5%86%B2%E7%AE%97%E6%B3%95/"/>
    
    <category term="采样" scheme="http://chuquan.me/tags/%E9%87%87%E6%A0%B7/"/>
    
    <category term="走样" scheme="http://chuquan.me/tags/%E8%B5%B0%E6%A0%B7/"/>
    
    <category term="滤波" scheme="http://chuquan.me/tags/%E6%BB%A4%E6%B3%A2/"/>
    
    <category term="傅里叶级数" scheme="http://chuquan.me/tags/%E5%82%85%E9%87%8C%E5%8F%B6%E7%BA%A7%E6%95%B0/"/>
    
    <category term="卷积" scheme="http://chuquan.me/tags/%E5%8D%B7%E7%A7%AF/"/>
    
  </entry>
  
</feed>
