```
├── .gitignore
├── Image/
├── AppendixA/
├── A.1.png
├── A.10.png
├── A.11.png
├── A.12.png
├── A.13.png
├── A.2.png
├── A.3.png
├── A.4.png
├── A.5.png
├── A.6.png
├── A.7.png
├── A.8.png
├── A.9.png
├── AppendixD/
├── D.1.png
├── D.2.png
├── AppendixE/
├── E.1.png
├── E.2.png
├── E.3.png
├── E.4.png
├── E.5.png
├── chapter1/
├── figure1.1.png
├── figure1.2.png
├── figure1.3.png
├── figure1.4.png
├── figure1.5.png
├── figure1.6.png
├── figure1.7.png
├── figure1.8.png
├── figure1.9.png
├── image2.19.png
├── table1.1.png
├── chapter2/
├── figure2.1.png
├── figure2.10.png
├── figure2.11.png
├── figure2.12.png
├── figure2.13.png
├── figure2.14.png
├── figure2.15.png
├── figure2.16.png
├── figure2.17.png
├── figure2.18.png
├── figure2.19.png
├── figure2.2.png
├── figure2.3.png
├── figure2.4.png
├── figure2.5.png
├── figure2.6.png
├── figure2.7.png
├── figure2.8.png
├── figure2.9.png
├── chapter3/
├── figure3.1.png
├── figure3.10.png
├── figure3.11.png
├── figure3.12.png
├── figure3.13.png
├── figure3.14.png
├── figure3.15.png
├── figure3.16.png
├── figure3.17.png
├── figure3.18.png
├── figure3.19.png
├── figure3.2.png
├── figure3.20.png
├── figure3.21.png
├── figure3.22.png
├── figure3.23.png
├── figure3.24.png
├── figure3.25.png
├── figure3.26.png
├── figure3.3.png
├── figure3.4.png
├── figure3.5.png
├── figure3.6.png
├── figure3.7.png
├── figure3.8.png
├── figure3.9.png
├── chapter4/
├── figure4.1.png
├── figure4.10.png
├── figure4.11.png
├── figure4.12.png
├── figure4.13.png
├── figure4.14.png
├── figure4.15.png
├── figure4.16.png
├── figure4.17.png
├── figure4.18.png
├── figure4.2.png
├── figure4.3.png
├── figure4.4.png
├── figure4.5.png
├── figure4.6.png
├── figure4.7.png
├── figure4.8.png
├── figure4.9.png
├── chapter5/
├── figure5.1.png
├── figure5.10.png
├── figure5.11.png
├── figure5.12.png
├── figure5.13.png
├── figure5.14.png
├── figure5.15.png
├── figure5.16.png
├── figure5.17.png
├── figure5.2.png
├── figure5.3.png
├── figure5.4.png
├── figure5.5.png
├── figure5.6.png
├── figure5.7.png
├── figure5.8.png
├── figure5.9.png
├── chapter6/
├── figure6.1.png
├── figure6.10.png
├── figure6.11.png
├── figure6.12.png
├── figure6.13.png
├── figure6.14.png
├── figure6.15.png
├── figure6.16.png
├── figure6.17.png
├── figure6.18.png
├── figure6.2.png
├── figure6.3.png
├── figure6.4.png
├── figure6.5.png
├── figure6.6.png
├── figure6.7.png
├── figure6.8.png
├── figure6.9.png
├── chapter7/
├── figure7.1.png
├── figure7.10.png
├── figure7.11.png
├── figure7.12.png
├── figure7.13.png
├── figure7.14.png
├── figure7.15.png
├── figure7.16.png
├── figure7.17.png
├── figure7.18.png
├── figure7.19.png
├── figure7.2.png
├── figure7.20.png
├── figure7.21.png
├── figure7.3.png
├── figure7.4.png
├── figure7.5.png
├── figure7.6.png
├── figure7.7.png
├── figure7.8.png
├── figure7.9.png
├── table_7.1.png
├── logo.png
├── LICENSE.txt
├── README.md
├── cn-Book/
├── 1.çÂÂ解大è¯Âè¨Â模åÂÂ.md
├── 2.å¤ÂçÂÂæÂÂæÂ¾Â°æÂ®.md
├── 3.å®Âç°注æÂÂÃ¥ÂÂæÂºå¶.md
├── 4.ä»Âé¶å¼Âå§Âå®Âç°ä¸Â个ç¨äºÂæÂÂæÂÂÂæÂÂç GPT 模åÂÂ.md
├── 5.å¨æÂ æ Âè®°æÂ°æÂ®éÂÂä¸Âè¿Âè¡Âé¢Âè®Âç»Â.md
├── 6.ç¨äºÂÃ¥ÂÂ类任å¡çÂÂå¾®è°Â.md
├── 7.æÂÂ令éµ循微è°Â.md
```
## /.gitignore
```gitignore path="/.gitignore"
.DS_Store
```
## /Image/AppendixA/A.1.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/AppendixA/A.1.png
## /Image/AppendixA/A.10.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/AppendixA/A.10.png
## /Image/AppendixA/A.11.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/AppendixA/A.11.png
## /Image/AppendixA/A.12.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/AppendixA/A.12.png
## /Image/AppendixA/A.13.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/AppendixA/A.13.png
## /Image/AppendixA/A.2.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/AppendixA/A.2.png
## /Image/AppendixA/A.3.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/AppendixA/A.3.png
## /Image/AppendixA/A.4.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/AppendixA/A.4.png
## /Image/AppendixA/A.5.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/AppendixA/A.5.png
## /Image/AppendixA/A.6.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/AppendixA/A.6.png
## /Image/AppendixA/A.7.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/AppendixA/A.7.png
## /Image/AppendixA/A.8.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/AppendixA/A.8.png
## /Image/AppendixA/A.9.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/AppendixA/A.9.png
## /Image/AppendixD/D.1.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/AppendixD/D.1.png
## /Image/AppendixD/D.2.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/AppendixD/D.2.png
## /Image/AppendixE/E.1.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/AppendixE/E.1.png
## /Image/AppendixE/E.2.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/AppendixE/E.2.png
## /Image/AppendixE/E.3.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/AppendixE/E.3.png
## /Image/AppendixE/E.4.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/AppendixE/E.4.png
## /Image/AppendixE/E.5.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/AppendixE/E.5.png
## /Image/chapter1/figure1.1.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter1/figure1.1.png
## /Image/chapter1/figure1.2.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter1/figure1.2.png
## /Image/chapter1/figure1.3.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter1/figure1.3.png
## /Image/chapter1/figure1.4.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter1/figure1.4.png
## /Image/chapter1/figure1.5.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter1/figure1.5.png
## /Image/chapter1/figure1.6.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter1/figure1.6.png
## /Image/chapter1/figure1.7.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter1/figure1.7.png
## /Image/chapter1/figure1.8.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter1/figure1.8.png
## /Image/chapter1/figure1.9.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter1/figure1.9.png
## /Image/chapter1/image2.19.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter1/image2.19.png
## /Image/chapter1/table1.1.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter1/table1.1.png
## /Image/chapter2/figure2.1.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter2/figure2.1.png
## /Image/chapter2/figure2.10.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter2/figure2.10.png
## /Image/chapter2/figure2.11.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter2/figure2.11.png
## /Image/chapter2/figure2.12.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter2/figure2.12.png
## /Image/chapter2/figure2.13.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter2/figure2.13.png
## /Image/chapter2/figure2.14.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter2/figure2.14.png
## /Image/chapter2/figure2.15.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter2/figure2.15.png
## /Image/chapter2/figure2.16.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter2/figure2.16.png
## /Image/chapter2/figure2.17.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter2/figure2.17.png
## /Image/chapter2/figure2.18.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter2/figure2.18.png
## /Image/chapter2/figure2.19.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter2/figure2.19.png
## /Image/chapter2/figure2.2.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter2/figure2.2.png
## /Image/chapter2/figure2.3.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter2/figure2.3.png
## /Image/chapter2/figure2.4.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter2/figure2.4.png
## /Image/chapter2/figure2.5.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter2/figure2.5.png
## /Image/chapter2/figure2.6.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter2/figure2.6.png
## /Image/chapter2/figure2.7.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter2/figure2.7.png
## /Image/chapter2/figure2.8.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter2/figure2.8.png
## /Image/chapter2/figure2.9.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter2/figure2.9.png
## /Image/chapter3/figure3.1.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.1.png
## /Image/chapter3/figure3.10.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.10.png
## /Image/chapter3/figure3.11.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.11.png
## /Image/chapter3/figure3.12.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.12.png
## /Image/chapter3/figure3.13.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.13.png
## /Image/chapter3/figure3.14.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.14.png
## /Image/chapter3/figure3.15.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.15.png
## /Image/chapter3/figure3.16.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.16.png
## /Image/chapter3/figure3.17.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.17.png
## /Image/chapter3/figure3.18.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.18.png
## /Image/chapter3/figure3.19.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.19.png
## /Image/chapter3/figure3.2.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.2.png
## /Image/chapter3/figure3.20.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.20.png
## /Image/chapter3/figure3.21.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.21.png
## /Image/chapter3/figure3.22.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.22.png
## /Image/chapter3/figure3.23.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.23.png
## /Image/chapter3/figure3.24.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.24.png
## /Image/chapter3/figure3.25.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.25.png
## /Image/chapter3/figure3.26.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.26.png
## /Image/chapter3/figure3.3.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.3.png
## /Image/chapter3/figure3.4.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.4.png
## /Image/chapter3/figure3.5.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.5.png
## /Image/chapter3/figure3.6.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.6.png
## /Image/chapter3/figure3.7.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.7.png
## /Image/chapter3/figure3.8.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.8.png
## /Image/chapter3/figure3.9.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter3/figure3.9.png
## /Image/chapter4/figure4.1.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter4/figure4.1.png
## /Image/chapter4/figure4.10.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter4/figure4.10.png
## /Image/chapter4/figure4.11.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter4/figure4.11.png
## /Image/chapter4/figure4.12.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter4/figure4.12.png
## /Image/chapter4/figure4.13.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter4/figure4.13.png
## /Image/chapter4/figure4.14.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter4/figure4.14.png
## /Image/chapter4/figure4.15.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter4/figure4.15.png
## /Image/chapter4/figure4.16.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter4/figure4.16.png
## /Image/chapter4/figure4.17.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter4/figure4.17.png
## /Image/chapter4/figure4.18.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter4/figure4.18.png
## /Image/chapter4/figure4.2.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter4/figure4.2.png
## /Image/chapter4/figure4.3.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter4/figure4.3.png
## /Image/chapter4/figure4.4.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter4/figure4.4.png
## /Image/chapter4/figure4.5.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter4/figure4.5.png
## /Image/chapter4/figure4.6.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter4/figure4.6.png
## /Image/chapter4/figure4.7.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter4/figure4.7.png
## /Image/chapter4/figure4.8.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter4/figure4.8.png
## /Image/chapter4/figure4.9.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter4/figure4.9.png
## /Image/chapter5/figure5.1.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter5/figure5.1.png
## /Image/chapter5/figure5.10.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter5/figure5.10.png
## /Image/chapter5/figure5.11.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter5/figure5.11.png
## /Image/chapter5/figure5.12.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter5/figure5.12.png
## /Image/chapter5/figure5.13.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter5/figure5.13.png
## /Image/chapter5/figure5.14.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter5/figure5.14.png
## /Image/chapter5/figure5.15.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter5/figure5.15.png
## /Image/chapter5/figure5.16.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter5/figure5.16.png
## /Image/chapter5/figure5.17.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter5/figure5.17.png
## /Image/chapter5/figure5.2.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter5/figure5.2.png
## /Image/chapter5/figure5.3.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter5/figure5.3.png
## /Image/chapter5/figure5.4.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter5/figure5.4.png
## /Image/chapter5/figure5.5.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter5/figure5.5.png
## /Image/chapter5/figure5.6.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter5/figure5.6.png
## /Image/chapter5/figure5.7.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter5/figure5.7.png
## /Image/chapter5/figure5.8.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter5/figure5.8.png
## /Image/chapter5/figure5.9.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter5/figure5.9.png
## /Image/chapter6/figure6.1.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter6/figure6.1.png
## /Image/chapter6/figure6.10.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter6/figure6.10.png
## /Image/chapter6/figure6.11.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter6/figure6.11.png
## /Image/chapter6/figure6.12.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter6/figure6.12.png
## /Image/chapter6/figure6.13.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter6/figure6.13.png
## /Image/chapter6/figure6.14.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter6/figure6.14.png
## /Image/chapter6/figure6.15.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter6/figure6.15.png
## /Image/chapter6/figure6.16.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter6/figure6.16.png
## /Image/chapter6/figure6.17.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter6/figure6.17.png
## /Image/chapter6/figure6.18.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter6/figure6.18.png
## /Image/chapter6/figure6.2.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter6/figure6.2.png
## /Image/chapter6/figure6.3.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter6/figure6.3.png
## /Image/chapter6/figure6.4.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter6/figure6.4.png
## /Image/chapter6/figure6.5.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter6/figure6.5.png
## /Image/chapter6/figure6.6.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter6/figure6.6.png
## /Image/chapter6/figure6.7.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter6/figure6.7.png
## /Image/chapter6/figure6.8.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter6/figure6.8.png
## /Image/chapter6/figure6.9.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter6/figure6.9.png
## /Image/chapter7/figure7.1.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/figure7.1.png
## /Image/chapter7/figure7.10.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/figure7.10.png
## /Image/chapter7/figure7.11.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/figure7.11.png
## /Image/chapter7/figure7.12.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/figure7.12.png
## /Image/chapter7/figure7.13.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/figure7.13.png
## /Image/chapter7/figure7.14.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/figure7.14.png
## /Image/chapter7/figure7.15.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/figure7.15.png
## /Image/chapter7/figure7.16.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/figure7.16.png
## /Image/chapter7/figure7.17.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/figure7.17.png
## /Image/chapter7/figure7.18.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/figure7.18.png
## /Image/chapter7/figure7.19.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/figure7.19.png
## /Image/chapter7/figure7.2.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/figure7.2.png
## /Image/chapter7/figure7.20.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/figure7.20.png
## /Image/chapter7/figure7.21.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/figure7.21.png
## /Image/chapter7/figure7.3.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/figure7.3.png
## /Image/chapter7/figure7.4.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/figure7.4.png
## /Image/chapter7/figure7.5.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/figure7.5.png
## /Image/chapter7/figure7.6.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/figure7.6.png
## /Image/chapter7/figure7.7.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/figure7.7.png
## /Image/chapter7/figure7.8.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/figure7.8.png
## /Image/chapter7/figure7.9.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/figure7.9.png
## /Image/chapter7/table_7.1.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/chapter7/table_7.1.png
## /Image/logo.png
Binary file available at https://raw.githubusercontent.com/skindhu/Build-A-Large-Language-Model-CN/refs/heads/main/Image/logo.png
## /LICENSE.txt
MIT License
Copyright (c) 2023-2024 Sebastian Raschka
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## /README.md
# Build a Large Language Model (From Scratch) 中文版
随着大语言模型(LLM)技术的飞速发展,越来越多的应用开始渗透到我们的工作和日常生活中。从智能助手、自动翻译到内容生成,LLM 已经成为推动人工智能发展的关键技术之一。这些技术不仅影响着企业和科研领域,还在教育、医疗、金融等多个行业引发了深远的变革。
[《Build a Large Language Model (From Scratch)》](https://www.manning.com/books/build-a-large-language-model-from-scratch)是一本深入探讨大语言模型原理与实现的电子书,适合希望深入了解 GPT 等大模型架构、训练过程及应用开发的学习者。为了让更多中文读者能够接触到这本极具价值的教材,我决定将其翻译成中文,并通过 GitHub 进行开源共享。
学习大语言模型的原理和实现,不仅有助于理解 AI 如何模仿人类的语言处理能力,也为开发者提供了深入掌握模型训练、调优、部署等技术的机会。无论是从事 AI 研究的学者,还是希望在实际项目中应用大语言模型的开发者,都能从中受益。
随着 LLM 技术的广泛应用,掌握其基础原理和实现方法将成为每一位 AI 从业者必备的技能。通过学习和研究大语言模型,我们不仅能更好地理解当前的技术发展,还能为未来的创新和突破奠定基础。
## 项目简介
+ 本项目还提供了原版的英文电子书(存放在 e-Book 目录中),对于英语基础较好的读者,我们建议尽量阅读原版书籍。毕竟,翻译过程是对原文的自我解读,难以做到完全与原版的思想和表达一致。
+ 为了兼顾翻译效率与质量,我采用了分阶段的翻译方案:首先开发了一个 AI 翻译助手,负责在大模型知识领域内逐章、逐节、逐段进行粗翻译;接着,由另一个 AI Agent 对翻译内容进行审查与修正;最后,我会进行人工精细翻译,确保翻译的准确性和流畅度。
+ 在原版英文书籍中,有一些内容与书中的主旨关系相对较弱,因此常常被简略提及。但这些内容的深入理解能够帮助我们更好地掌握大模型的设计理念。因此,在翻译过程中,我也加入了自己在遇到不理解或不了解的部分时的思考和解读,希望能帮助读者更深入地理解大模型的各个方面。
+ 书中提供了所有需要的实践代码,强烈建议读者按照书中的教程进行实操,并在实现的过程中结合日常使用的各类大模型,深入思考其背后的原理。如果遇到不理解的部分,可以进一步查阅相关资料。(官方也针对书籍提供了配套的[代码库](https://github.com/rasbt/LLMs-from-scratch))
## 项目结构
| 目录 | 说明 |
| ------- | ---------------------------------------------------- |
| e-Book | 原版英文书籍,建议英语基础较好的读者直接阅读 |
| cn-Book | 翻译后的中文版,按照章节组织,与原版英文书籍一一对应 |
| Image | 原版英文书籍中的所有图片,也全部经过翻译 |
### 全书章节
+ [第一章:理解大语言模型](./cn-Book/1.理解大语言模型.md)
+ [第二章:处理文本数据](./cn-Book/2.处理文本数据.md)
+ [第三章:实现注意力机制](./cn-Book/3.实现注意力机制.md)
+ [第四章:从零开始实现一个用于文本生成的 GPT 模型](./cn-Book/4.从零开始实现一个用于文本生成的%20GPT%20模型.md)
+ [第五章:在无标记数据集上进行预训练](./cn-Book/5.在无标记数据集上进行预训练.md)
+ [第六章:用于分类任务的微调](./cn-Book/6.用于分类任务的微调.md)
+ [第七章:指令遵循微调](./cn-Book/7.指令遵循微调.md)
+ [附录A:PyTorch简介](./cn-Book/附录A.PyTorch简介.md)
+ [附录B:参考文献和扩展阅读](./cn-Book/附录B.参考文献和扩展阅读.md)
+ [附录C:习题解答](./cn-Book/附录C.习题解答.md)
+ [附录D:给训练循环添加高级技巧](./cn-Book/附录D.给训练循环添加高级技巧.md)
+ [附录E:使用 LoRA 的参数高效微调](./cn-Book/附录E.使用LoRA的参数高效微调.md)
## 个人思考
巴克莱在最近发布的研报中提出了一份“AI路线图”,描绘了未来AI技术应用的演进路径,我个人比较认同。报告指出,AI的应用将经历三个重要阶段,首先是当下的**第一阶段**:聊天机器人和早期的AI助理(Copilot),因为目前主要是侧重于基础设置的建设和模型能力的竞赛。接下来在2025-2026年将迎来“真AI代理时代”的人**第二阶段**,这一阶段的核心在于能够自主完成任务的AI代理的广泛应用。与聊天机器人和Copilot不同,AI代理能完成相对复杂的任务,尽量减少人类的直接干预。而在2027年以后,AI技术将进一步进入“数字员工与机器人时代”**第三阶段**”(应该是所谓的具身智能),在企业应用中,AI代理可能演变成独立完成任务的“数字员工”,在消费者市场,智能机器人将开始逐步融入家庭生活,承担简单和重复性的日常任务。
可以看到,这一发展趋势的推断依据是人类对于AI工作过程的介入越来越少(意味着AI能力越来越强),再结合具身形态,必然会帮人类承担越来越多的工作。据巴克莱估计,到这一阶段,AI技术的普及将达到互联网用户的规模,突破40亿人。
**那么作为IT从业者,从现在开始应该做哪些准备尽量保障自己在将来不会被淘汰,根据我的浅薄认知做一下梳理和预测:**
+ **持续学习与技能提升**
+ 尽量去掌握大模型技术原理,而不要仅仅关注各种花里胡哨的应用层面的资讯。学习原理,才能透过现象看本质,比如该项目从零到一通过编码的方式带我们了解如何准备和清理训练数据、分词、词嵌入、Transformer架构的实现、模型精调、实现指令遵循等,对于大模型的理解非常有帮助。
+ 保持和加深对业务的理解:AI最终是要落地到实际的业务中去解决某一类问题,那么如何对业务问题进行抽象从而设计出高效的AI工作流是我们要关注和解决的问题,这也取决于我们对业务的理解程度。
+ **拥抱AI工具和技术**
+ 多在日常的工作和生活中使用各类AI工具,这样才能逐渐对各种不同的AI应用思路的认知提升。
+ 多参与实际的AI项目,积累经验,从中学习如何将AI应用到实际的问题中,提升解决问题的能力。
+ **够快速适应变化**
+ 尽量保持开放的形态,不要因为年龄慢慢变大,生活中琐事变多而失去好奇心。我们要对新技术、新工具保持好奇和开放的态度,快速适应技术的变化和市场的需求。
+ 接受未来可能带来的变化,积极寻找学习和发展的机会。
## 中文版电子书阅读过程中可能遇到的问题
在阅读过程中,可能会出现图片无法加载的问题,这一般是由于 Github 的文件服务器的 DNS 被污染导致。遇到该问题时,我们可以先查询出 Github 文件服务器(域名是`raw.githubusercontent.com`)的真实 IP,这可以在 Terminal 中执行如下命令获取:
```bash
nslookup raw.githubusercontent.com 114.114.114.114
```
比如我执行后的输出结果如下:
```
Server: 114.114.114.114
Address: 114.114.114.114#53
Non-authoritative answer:
Name: raw.githubusercontent.com
Address: 185.199.111.133
Name: raw.githubusercontent.com
Address: 185.199.110.133
Name: raw.githubusercontent.com
Address: 185.199.109.133
```
接着可以修改`/etc/hosts`文件,将域名重定向到正确的 ip 地址上(`sudo vim /etc/hosts`):
```bas
# 读者可以根据自己的输出填入正确的IP地址,当然可以先 ping 一下这些IP,选择通畅且速度最快的
185.199.108.133 raw.githubusercontent.com
185.199.108.133 githubusercontent.com
```
## 若希望了解更多AI探索相关的内容,可关注作者公众号
## /cn-Book/1.çÂÂ解大è¯Âè¨Â模åÂÂ.md
本章涵盖以下内容:
- **大语言模型(LLM)背后基本概念的高级解释**
- **对大语言模型(如 ChatGPT 平台上使用的模型)所源自的 Transformer 架构的深入了解**
- **从零开始构建大语言模型的计划**
------
- [1.1 LLM 是什么?](#11-llm-是什么)
- [1.2 LLM 的应用](#12-llm-的应用)
- [1.3 构建和使用 LLM 的步骤](#13-构建和使用-llm-的步骤)
- [1.4 介绍 Transformer 架构](#14-介绍-transformer-架构)
- [1.5 利用大型数据集](#15-利用大型数据集)
- [1.6 深入剖析GPT架构](#16-深入剖析gpt架构)
- [1.7 构建大语言模型](#17-构建大语言模型)
- [1.8 本章摘要](#18-本章摘要)
------
大语言模型 (LLM),如 OpenAI 的 ChatGPT,是近年来发展起来的深度神经网络模型。这些模型为自然语言处理 (NLP) 开辟了一个新时代。在大语言模型出现之前,传统方法在电子邮件垃圾分类等分类任务中表现良好,但通常在需要复杂理解和生成能力的语言任务上表现不佳,例如解析详细指令、进行上下文分析,或生成连贯且符合上下文的原创文本。例如,早期的语言模型无法根据关键词列表撰写电子邮件,而这个任务对现代 LLM 来说却非常简单。
LLM 具备理解、生成和解释人类语言的卓越能力。然而,我们需要澄清的是,当我们说语言模型“理解”时,并不是说它们具有人类的意识或理解能力,而是指它们能够以看起来连贯且符合上下文的方式处理和生成文本。
得益于深度学习的进展,深度学习是机器学习和人工智能 (AI) 的一个子集,主要关注神经网络,LLM 可以基于深度学习理论在海量文本数据上进行训练。这使得 LLM 能够捕捉到比以往方法更深层的上下文信息和人类语言的细微差别。因此,LLM 在各种自然语言处理 (NLP) 任务中的表现得到了显著提升,包括文本翻译、情感分析、问答等。
当代 LLM 与早期 NLP 模型之间的另一个重要区别在于,早期的 NLP 模型通常是为特定任务而设计的,例如文本分类、语言翻译等。虽然这些早期模型在其特定应用中表现出色,但 LLM 在各种自然语言处理 (NLP) 任务中展现了更广泛的能力。
LLM 的成功可以归因于支撑 LLM 的 Transformer 架构,以及 LLM 训练所用的海量数据。这使得它们能够捕捉到多种语言的细微差别、上下文和模式,而这些都是难以手动编码的。
这种转向基于 Transformer 架构的模型和大规模训练数据集来训练 LLM,已经从根本上改变了自然语言处理 (NLP) 领域,为理解和与人类语言互动提供了更强大的工具。
从本章开始,我们将奠定实现本书主要目标的基础:通过逐步在代码中实现一个基于 transformer 架构的类似 ChatGPT 的 LLM,以帮助理解 LLM。
## 1.1 LLM 是什么?
LLM(大语言模型)是一个旨在理解、生成和响应人类文本的神经网络。这些模型是深度神经网络,在海量文本数据上训练,基本涵盖了互联网上大部分公开可用的文本数据集。
“大语言模型”中的“大”指的是模型的参数规模和用于训练的庞大数据集。这类模型通常包含数十亿甚至数百亿的参数,这些参数是网络中的可调节权重,训练过程中通过优化来预测序列中的下一个单词。预测下一个单词是合理的,因为这利用了语言的序列特性,帮助模型理解文本中的上下文、结构和关系。然而,这只是一项非常简单的任务,因此许多研究人员对其能够产生如此强大的模型感到惊讶。我们将在后面的章节中逐步讨论并实现下一个单词的训练过程。
LLM 采用了一种称为 Transformer 的架构(在第 1.4 节中将详细讨论),这使得它们在做预测时能够对输入的不同部分进行选择性关注,因此特别擅长处理人类语言的细微差别和复杂性。
由于 LLM 能够生成文本,因此它们通常被称为一种生成式人工智能 (AI),常缩写为生成 AI 或 GenAI。如图 1.1 所示,人工智能涵盖了创造能执行类似人类智能任务的更广泛领域,包括理解语言、识别模式和做出决策,并包括机器学习和深度学习等子领域。
用于实现人工智能的算法是机器学习领域的核心。机器学习往往不需要明确的编程实现,而是涉及可以从数据中学习并基于数据做出预测或决策的算法研究。举例来说,垃圾邮件过滤器就是机器学习的一个实际应用。与其手动编写规则来识别垃圾邮件,不如将标记为垃圾邮件和合法邮件的电子邮件示例输入给机器学习算法。通过最小化训练数据集上的预测误差,模型能够学习识别垃圾邮件的模式和特征,从而将新邮件分类为垃圾邮件或合法邮件。
如图 1.1 所示,深度学习是机器学习的一个子集,专注于使用三层或更多层的神经网络(即深度神经网络)来建模数据中的复杂模式和抽象。与深度学习不同,传统机器学习需要手动提取特征。这意味着人类专家需要识别并选择最相关的特征供模型使用。
虽然当前人工智能领域主要由机器学习和深度学习主导,但它也涵盖了其他方法,例如基于规则的系统、遗传算法、专家系统、模糊逻辑和符号推理。
回到垃圾邮件分类的例子,在传统机器学习中,人类专家会手动提取电子邮件文本中的特征,例如某些触发词的频率(“奖品”、“获胜”、“免费”)、感叹号的数量、全大写单词的使用,或者是否存在可疑链接。基于这些专家定义的特征创建的数据集随后用于训练模型。与传统机器学习不同,深度学习不需要手动提取特征,这意味着人类专家不需要为深度学习模型识别和选择最相关的特征。(不过,无论是在传统机器学习还是深度学习的垃圾邮件分类中,仍然需要收集标签,如垃圾邮件或非垃圾邮件,而这些标签需要由专家或用户进行收集。)
接下来的章节将介绍 LLM 能解决的问题、LLM 面临的挑战,以及我们将在本书中实现的通用 LLM 架构。
## 1.2 LLM 的应用
由于具备解析和理解非结构化文本数据的高级能力,LLM 在多个领域有着广泛的应用。目前,LLM 被广泛用于机器翻译、新文本生成(见图 1.2)、情感分析、文本摘要等多种任务。最近,LLM 还被用于内容创作,比如撰写小说、文章,甚至计算机代码。
LLM 还可以支持复杂的聊天机器人和虚拟助手,例如 OpenAI 的 ChatGPT 或谷歌的 Gemini(以前称为 Bard),这些助手能够回答用户的问题,并提升传统搜索引擎的功能,如 Google Search 和 Microsoft Bing。
此外,LLM 还可以有效地从医学或法律等专业领域的大量文本中检索知识。这包括筛选文档、总结长段落以及回答技术性问题。
总之,LLM 在自动化几乎所有涉及文本解析和生成的任务中都是不可或缺的。它们的应用几乎是无限的,随着我们不断创新和探索这些模型的新用法,LLM 显然有潜力重新定义我们与技术的关系,使其变得更加对话式、直观和易于访问。
在本书中,我们将重点了解 LLM 的工作原理,从基础开始,编码一个能够生成文本的 LLM。我们还将学习使 LLM 执行查询的技术,包括回答问题、总结文本、将文本翻译成不同语言等。换句话说,在本书中,我们将通过一步步构建的方式,学习复杂的 LLM 助手(如 ChatGPT)的工作原理。
## 1.3 构建和使用 LLM 的步骤
为什么我们应该构建自己的 LLM?从头开始编码一个 LLM 是理解其工作机制和局限性的绝佳练习。同时,这也使我们具备了对现有开源 LLM 架构进行预训练或微调的知识,以便将其应用于我们特定领域的数据集或任务。
研究表明,在建模性能方面,专为特定任务或领域定制的 LLM 通常能超过通用的 LLM,比如 ChatGPT,这些通用模型设计用于多种应用场景。例如,BloombergGPT 是一个专门针对金融领域的模型,还有针对医学问答定制的 LLM(有关更多细节,请参阅附录 B 的进一步阅读和参考文献部分)
使用定制的 LLM 有多个优势,尤其是在数据隐私方面。例如,公司可能因为保密问题而不愿与像 OpenAI 这样的第三方 LLM 提供商分享敏感数据。此外,开发定制的 LLM 可以直接在客户的设备上部署,比如笔记本电脑和智能手机,这是像 Apple 这样的公司当前正在探索的方向。这种针对LLM的本地部署实现能够显著降低响应延迟和服务器相关的成本。同时,定制的 LLM 使开发者拥有完全的自主权,能够根据需要控制模型的更新和修改。
创建 LLM 的一般过程包括预训练和微调。术语 "pre" 在 "pretraining" 中指的是初始阶段,此时模型(如 LLM)在一个大型且多样化的数据集上进行训练,以便获得对语言的广泛理解。预训练模型随后作为基础资源,可以通过微调进一步优化。微调是指模型在一个更针对特定任务或领域的数据集上进行专门训练。包含预训练和微调的这种两阶段训练方法在图 1.3 中进行了说明。
> [!TIP]
>
> **个人思考:** 预训练的数据集已经学习好了语言模型的基础能力,包括语法、词汇、语言结构,可以相对准确的预测下一个token。而微调则是利用特定领域的数据来让模型适应某些特定的任务。微调一般有两种方式:
>
> + 全权重的微调,这种方式会在训练过程中对模型的所有预训练权重进行调整,但由于权重已经经过预训练,大多数情况下,微调只会对预训练权重进行微小调整,而不是大幅度改变。这种方式能够让模型保持原有的语言生成能力,同时使其在特定任务上表现得更好。
> + 冻结部分权重的微调,一般冻结低层(往往是学习到的基础语言特征),对高层的权重进行调整。这种微调方式常在需要加速训练,或者数据量较小,全权重微调可能导致过拟合的情况下使用。
如图 1.3 所示,创建 LLM 的第一步是用大量文本数据进行训练,这些数据一般被称为原始文本。这里的 "raw" 指的是这些数据只是普通文本,没有任何标注信息[^1] 。(可以进行过滤,比如去除格式字符或未知语言的文档。)
LLM 的第一阶段训练被称为预训练,旨在创建一个初始的预训练 LLM,通常称为基础模型。GPT-3 模型是一个典型例子(ChatGPT 中原始模型的前身)。该模型可以完成文本补全,即对用户写了一半的句子进行续写。同时,它还具有有限的少量示例学习能力,这意味着它可以在仅有少量示例的情况下学习执行新任务,而不需要大量的训练数据。下一节“介绍 transformer 架构”将对此进行进一步说明。
在从大型文本数据集上训练得到预训练的 LLM 后,该LLM会学习预测文本中的下一个单词。我们可以在优质的标注数据上对 LLM 进行进一步训练,这个过程称为微调。
微调 LLM 的两个最流行的类别是指令微调和分类任务微调。在指令微调中,标注数据集包含指令和答案对,例如用于翻译文本的查询及其正确翻译。在分类微调中,标注数据集由文本及其对应的类别标签组成,比如与垃圾邮件和非垃圾邮件标签相关的电子邮件。
在本书中,我们将介绍 LLM 的预训练和微调的代码实现,并将在预训练基础 LLM 后,深入探讨指令微调和分类微调的具体细节。
## 1.4 介绍 Transformer 架构
大多数现代 LLM 基于 transformer 架构,这是一种深度神经网络架构,首次在 2017 年的论文《Attention Is All You Need》中提出。为了理解 LLM,我们需要简要回顾一下最初为机器翻译开发的原始 Transformer,该架构用于将英文文本翻译成德文和法文。图 1.4 显示了 Transformer 架构的简化版本。
图 1.4 中的 Transformer 架构由两个子模块组成:编码器和解码器。编码器模块处理文本输入,将其编码为一系列数值表示或向量,以捕捉输入的上下文信息。然后,解码器模块利用这些编码向量生成输出文本。例如,在翻译任务中,编码器将源语言文本编码为向量,而解码器则将这些向量解码为目标语言的文本。编码器和解码器都由多个层通过自注意力机制相连。您可能会对输入的预处理和编码过程有许多疑问,这些将在后续章节的逐步实现中详细解答。
Transformers 和 LLM 的一个关键组成部分是自注意力机制(图中未显示),它使模型能够相对地权衡序列中不同单词或标记的重要性。这个机制帮助模型捕捉输入数据中的远程依赖关系和上下文关系,从而提高生成连贯且与上下文相关的输出的能力。不过,由于自注意力机制的复杂性,我们将在第三章中逐步讨论和实现它。此外,第二章《处理文本数据》中,我们也将讨论并实现创建模型输入所需的数据预处理步骤。
> [!TIP]
>
> **个人思考:** 早期用于翻译任务的模型一般使用RNN,RNN的核心是循环结构,也就是会把当前的输出和之前的状态结合起来,再输入到下一步。这样,网络就可以记住前面输入的信息,并把这些信息应用到后续的预测中。例如,当你输入一段文字时,RNN会记住前面的单词,以帮助理解后面的单词。但这种机制也有一个非常明显的不足:长距离依赖问题。虽然RNN能“记住”前面的信息,但它对非常长的序列记忆能力有限。随着序列变长,早期信息会逐渐被“遗忘”,导致长距离依赖的问题。这就像你在听一长段话,可能会逐渐忘记开头说的内容。而Transformer架构通过自注意力机制(后面详细介绍实现机制)实现能够关注序列中的任意位置,而不需要经过层层传递。因此,无论信息在序列中距离多远,Transformer都能有效地捕捉和利用长距离的依赖关系。
Transformer 架构的后续变体,包括 BERT(双向编码器表示来自 Transformers 的缩写)和各种 GPT 模型(生成预训练变换器的缩写),都是基于这一概念进行构建的,以适应不同的任务。(参考文献见附录 B。)
BERT 是基于原始 Transformer 架构的编码器子模块,与 GPT 的训练方法有所不同。GPT 主要用于生成任务,而 BERT 及其变体则专注于掩码词预测,即模型在给定句子中预测被掩码或隐藏的词,如图 1.5 所示。这种独特的训练策略使得 BERT 在文本分类任务中具备优势,包括情感预测和文档分类。作为应用实例,截至目前,Twitter 正在使用 BERT 来检测有害内容。
> [!TIP]
>
> **个人思考:** 为什么BERT适合用于文档分类或情感预测,这主要是基于BERT的训练模式,BERT也是基于Transformer架构,但它采用的是 **masked language model (MLM)** 训练方式,即在训练过程中,它会随机遮掩输入句子中的一些词(称为“masked”),并让模型预测这些被遮掩的词。这种训练策略被称为**掩蔽词预测**。这一独特的训练方法使得 BERT 能够更好地理解句子的上下文,因为它需要根据整句话的前后部分来预测被遮掩的词。这种双向(bidirectional)的训练使得 BERT 更适合处理需要全局上下文理解的任务,而文档分类或情感预测正是两种对于上下文语义理解要求非常高的场景。
另一方面,GPT 专注于原始 Transformer 架构中的解码器部分,被设计用于需要生成文本的任务。这些任务包括机器翻译、文本摘要、小说创作和编写代码等。在本章接下来的部分,我们将更详细地讨论 GPT 架构,并在本书中从零开始实现它。
GPT 模型主要是为文本补全任务设计和训练的,但它们在能力上展现出显著的多样性。这些模型擅长执行zero-shot 和few-shot 学习任务。zero-shot 学习指的是在没有先前具体示例的情况下,能够处理完全未见过的任务。而few-shot 学习则是指模型可以从用户提供的极少量示例中进行学习,如图 1.6 所示。
> [!NOTE]
>
> **TRANSFORMERS 与 LLM**
>
> 如今的 LLM 大部分是基于上一节提到的 Transformer 架构来实现。因此,在文献中,Transformers 和 LLM 常常被视为同义词。然而,值得注意的是,并非所有的 Transformers 都是 LLM,因为它们也可以用于计算机视觉。同时,并非所有的 LLM 都是基于 Transformers 的,市场上也有一些基于递归和卷积架构的大语言模型。这些替代方法的主要目的是提高 LLM 的计算效率。不过,这些替代架构能否与基于 Transformer 的 LLM 的能力相竞争,以及它们是否会在实际中得到应用,还需要进一步观察。为了简单起见,本书将“LLM”一词用来指代类似于 GPT 的基于 Transformer 的 LLM。(感兴趣的读者可以在本章末尾的进一步查找找到相关文献阅读。)
## 1.5 利用大型数据集
流行的 GPT 和 BERT 类模型的大型训练数据集代表了丰富而全面的文本语料库,涵盖数十亿个单词,涉及各种主题以及自然语言和计算机语言。为了提供一个具体的例子,表 1.1 总结了用于预训练 GPT-3 的数据集,这个模型是第一版 ChatGPT 的基础。
通过表1.1能得出的主要结论是,这个训练数据集的规模和多样性使得这些模型在各种任务中表现优异,包括不同语言的语法、语义和上下文信息,甚至还可以处理一些需要通用知识的任务。
> [!NOTE]
>
> **GPT-3 数据集细节**
>
> 表 1.1 展示了用于 GPT-3 的数据集。表中的“占比列(最后一列)”经过四舍五入误差调整后总和为 100%。尽管“token数量”这一列中的总计达到 5090 亿,但模型实际只在 3000 亿个token上进行训练。GPT-3 论文的作者没有解释为何模型没有在所有 5090 亿个token上进行训练。
>
> 对于上下文来说,考虑 CommonCrawl 数据集的规模,该数据集单独包含 4100 亿个token,存储需要大约 570 GB。相比之下,像 GPT-3 这样的模型后续版本,例如 Meta 的 LLaMA,已经扩展了其训练范围,加入了额外的数据来源,比如 Arxiv 的研究论文(92 GB)和 StackExchange 的代码相关问答(78 GB)。
>
> GPT-3 论文的作者没有公开训练数据集,但有一个与其相当的公开数据集名为 Dolma:由 Soldaini 等人在 2024 年发布的《用于 LLM 预训练研究的三万亿token开放语料库》(https://arxiv.org/abs/2402.00159)。不过,该数据集可能包括受版权保护的作品,其具体使用条款可能会根据预期的使用场景和国家有所不同。
这些模型的预训练特性使它们在后续任务中的微调变得非常灵活,因此它们也被称为基础模型或基模。预训练 LLM 需要消耗大量资源,且成本非常高。例如,GPT-3 的预训练费用估计为 460 万美元,通过云计算积分来计算[^2]。
好消息是,已经有许多经过预训练的开源LLM,可以作为通用工具来撰写、提取和编辑不在训练数据中的文本。此外,LLM可以在相对较小的数据集上进行微调,以减少所需的计算资源并提高特定任务的性能。
在本书中,我们将一步步实现预训练代码,并利用它来预训练一个 LLM。所有的计算都将在普通消费级硬件上进行。在实现预训练LLM的代码后,我们将学习如何重用公开可用的模型权重,并将这些权重加载到我们所实现的架构中,这样在本书后续微调 LLM 时,我们就可以跳过昂贵的预训练阶段。
## 1.6 深入剖析GPT架构
在本章之前,我们提到了GPT类模型、GPT-3和ChatGPT。现在让我们更深入地了解通用的GPT架构。首先,GPT 是“生成预训练变换器”(Generative Pretrained Transformer)的缩写,最初是在以下论文中提出的:
+ 《通过生成预训练改善语言理解(2018)》是由 OpenAI 的 Radford 等人撰写的,链接:http://cdn.openai.com/researchcovers/language-unsupervised/language_understanding_paper.pdf
GPT-3 是该模型的增强版,具有更多参数,并在更大的数据集上进行训练。而在 ChatGPT 中提供的原始模型是通过在一个大型指令数据集上微调 GPT-3 而创建的,这一过程使用了 OpenAI 的 InstructGPT 论文中的方法,我们将在第 7 章“使用人类反馈进行微调以遵循指令”中详细介绍。如图 1.6 所示,这些模型在文本完成方面表现出色,并且还能够进行拼写纠正、分类和语言翻译等其他任务。考虑到 GPT 模型是在相对简单的下一个单词预测任务上进行预训练的,这一点确实非常惊人,如图 1.7 所示。
下一个单词预测任务是一种自监督学习的方法,这是一种自我标注的形式。这意味着我们不需要专门收集训练数据的标签,而是可以利用数据本身的结构:我们可以把句子或文档中的下一个单词作为模型需要预测的标签。由于下一个单词预测任务允许我们“动态”生成标签,因此我们可以利用大量未标记的文本数据集来训练 LLM,这在第 1.5 节中也有讨论,即利用大型数据集。
与我们在 1.4 节讨论的原始 Transformer 架构相比,通用 GPT 架构相对简单。实际上,它仅包含解码器部分,而没有编码器,如图 1.8 所示。由于像 GPT 这样的解码器模型是通过逐字预测生成文本,因此它们被视为一种自回归模型。自回归模型会将之前的输出作为未来预测的输入。因此,在 GPT 中,每个新词的选择都是基于之前的文本序列,这样可以提高生成文本的连贯性。
像 GPT-3 这样的模型架构明显大于原始的 Transformer 模型。例如,原始的 Transformer 将编码器和解码器块重复了六次,而 GPT-3 具有 96 层 Transformer,总共有 1750 亿个参数。
GPT-3 于 2020 年推出,按照深度学习和大语言模型(LLM)开发的标准,如今看来,已经是很久以前了。然而,像 Meta 的 Llama 模型这样的最新架构依然基于相同的基本原理,仅做了些许修改。因此,理解 GPT 的重要性依旧不减。本书将专注于实现 GPT 背后的核心架构,并提供有关其他 LLM 所采用的特定调整的参考。
最后,值得注意的是,原始的 Transformer 模型由编码器和解码器块组成,专为语言翻译设计,但 GPT 模型虽然架构更大且仅包含解码器,却主要用于下一个词预测,但同时也具备执行翻译任务的能力。这一能力最初让研究人员感到意外,因为它出现在一个主要针对下一个词预测任务的模型中,而这个任务并没有专门针对翻译。
模型能够执行未明确训练的任务被称为“涌现行为”。这种能力不是通过明确的训练获得的,而是模型接触大量多语言数据和多样化上下文后自然而然涌现的结果。GPT 模型能够“学习”不同语言之间的翻译模式,并执行翻译任务,尽管它们并没有专门针对这些任务进行训练,这展示了这些大语言模型的优势和能力。我们可以在不需要为每个任务使用不同模型的情况下,完成多种任务。
## 1.7 构建大语言模型
在本章中,我们为理解LLM打下了基础。在本书的其余部分,我们将从零开始编码一个 LLM,使用 GPT 的基本理念作为框架,并分为三个阶段进行,如图 1.9 所示。
首先,我们将学习基本的数据预处理步骤,并编写 LLM 核心的注意力机制代码。
接下来,在第二阶段,我们将学习如何编写代码并预训练一个类似 GPT 的 LLM,能够生成新的文本。同时,我们还会介绍评估 LLM 的基本原理,这对开发强大的 NLP(自然语言处理)系统至关重要。
请注意,从头开始预训练一个 LLM 是一项庞大的工程,对于类似 GPT 的模型,计算成本可能高达数千到数百万美元。因此,第二阶段的重点是进行教学目的的训练,使用小型数据集。此外,本书还将提供关于如何加载公开可用的模型权重的代码示例。
最后,在第三阶段,我们将使用一个预训练好的 LLM,对其进行微调,使其能够执行指令,例如回答查询或进行文本分类——这些是在许多现实世界应用和研究中最常见的任务。
希望你能期待踏上这段激动人心的旅程!
## 1.8 本章摘要
+ LLM 已经彻底改变了自然语言处理的领域,之前自然语言处理主要依赖于显式的规则系统和较为简单的统计方法。LLM 的出现引入了新的深度学习驱动的方法,推动了对人类语言的理解、生成和翻译的进步。
+ 现代 LLM 的训练通常分为两个主要步骤:
+ 首先,它们在一个大型未标注的文本语料库中进行预训练,通过使用句子中下一个单词的预测作为“标签”。
+ 这些模型接下来会在一个较小的、有标签的目标数据集上进行微调,以遵循指令或执行分类任务。
+ LLM 基于Transformer架构。Transformer架构的核心理念是注意力机制,这使得 LLM 在逐字生成输出时,能够选择性地访问整个输入序列。
+ 原始的Transformer架构由一个用于解析文本的编码器和一个用于生成文本的解码器组成。
+ 生成文本和执行指令的 LLM,例如 GPT-3 和 ChatGPT,仅实现解码器模块,这使得架构更加简化。
+ 由数十亿个单词构成的大型数据集对预训练 LLM 至关重要。在本书中,我们将实现并在小型数据集上训练 LLM,以便用于教学,同时也会探讨如何加载公开可用的模型权重。
+ 类似 GPT 的模型的普遍预训练任务是预测句子中的下一个单词,但这些 LLM 显示出了“涌现”特性,例如具备分类、翻译或文本总结的能力。
+ 一旦 LLM 完成预训练,得到的基础模型就可以更高效地微调,以应对各种下游任务。
+ 在自定义数据集上微调过的 LLM 能够在特定任务上超越通用 LLM。
[^1]: 拥有机器学习背景的读者可能会注意到,传统机器学习模型和通过常规监督学习训练的深度神经网络通常需要标注数据。但在 LLM 的预训练阶段情况并非如此。在这一阶段,LLM 采用自监督学习,模型可以从输入数据中自动生成标签。这个概念将在本章后面的内容中进一步讨论。
[^2]: GPT-3,价值 4,600,000 美元的语言模型,https://www.reddit.com/r/MachineLearning/comments/h0jwoz/d_gpt3_the_4600000_language_model/
## /cn-Book/2.å¤ÂçÂÂæÂÂæÂ¾Â°æÂ®.md
本章涵盖以下内容:
+ **为大语言模型的训练准备文本数据集**
+ **将文本分割成词和子词token**
+ **字节对编码(Byte Pair Encoding,BPE):一种更为高级的文本分词技术**
+ **使用滑动窗口方法采样训练示例**
+ **将tokens转换为向量,输入到大语言模型中**
-----
- [2.1 理解词嵌入](#21-理解词嵌入)
- [2.2 文本分词](#22-文本分词)
- [2.3 将 tokens 转换为token IDs](#23-将-tokens-转换为token-ids)
- [2.4 添加特殊上下文token](#24-添加特殊上下文token)
- [2.5 字节对编码(Byte pair encoding)](#25-字节对编码byte-pair-encoding)
- [2.6 使用滑动窗口进行数据采样](#26-使用滑动窗口进行数据采样)
- [2.7 构建词嵌入层](#27-构建词嵌入层)
- [2.8 位置编码](#28-位置编码)
- [2.9 本章摘要](#29-本章摘要)
-----
在上一章中,我们介绍了大语言模型(LLM)的基本结构,并了解到它们会基于海量的文本数据集进行预训练。我们特别关注的是仅使用通用 Transformer 架构中解码器部分的 LLM,这也是 ChatGPT 和其他流行的类似 GPT 的 LLM 所依赖的模型。
在预训练阶段,LLM 逐字处理文本。通过使用下一个单词预测任务训练拥有数百万到数十亿参数的 LLM,最终能够生成具有出色能力的模型。这些模型随后可以进一步微调,以遵循指令或执行特定目标任务。然而,在我们接下来几章中实现和训练 LLM 之前,我们需要准备训练数据集,这也是本章的重点,如图 2.1 所示。
在本章中,您将学习如何为训练 LLM 准备输入文本。这包括将文本拆分为单个单词和子词token,并将这些token编码为 LLM 的向量表示。您还将了解一些先进的token分割方案,比如字节对编码,这种方法在像 GPT 这样的流行 LLM 中得到应用。最后,我们将实现一个采样和数据加载策略,以生成后续章节中训练 LLM 所需的输入输出数据对。
## 2.1 理解词嵌入
深度神经网络模型,包括 LLM,往往无法直接处理原始文本。这是因为文本是离散的分类数据,它与实现和训练神经网络所需的数学运算不兼容。因此,我们需要一种方法将单词表示为连续值向量。(对计算中向量和张量不熟悉的读者,可以在附录 A 的 A2.2 节中了解更多关于张量的内容。)
将数据转换为向量格式的过程通常被称为嵌入(Embedding)。我们可以通过特定的神经网络层或其他预训练的神经网络模型来对不同类型的数据进行嵌入,比如视频、音频和文本,如图 2.2 所示。
如图 2.2 所示,我们可以使用嵌入模型来处理多种不同的数据格式。然而,需要注意的是,不同的数据格式需要使用不同的嵌入模型。例如,专为文本设计的嵌入模型并不适用于音频或视频数据的嵌入。
> [!TIP]
>
> **个人思考:** 不同格式的数据源(如文本、图像、音频、视频)在处理和嵌入时,需要不同的模型和技术,原因在于它们的数据结构、特征和处理方式各不相同,因此需要针对性的方法将这些不同的数据类型转换为适合神经网络处理的向量表示。以下总结了不同数据源在嵌入时的一些区别:
>
> | 数据类型 | 数据特征 | 嵌入模型 | 主要特征 |
> | :------: | :------------------------: | :--------------------------------: | :------------------------: |
> | 文本 | 离散的、序列化的符号数据 | Word2Vec, GloVe, BERT, GPT 等 | 语义关系、上下文理解 |
> | 图像 | 二维像素网格,具有空间特征 | CNN(ResNet、VGG)、ViT | 形状、纹理、颜色等视觉特征 |
> | 音频 | 一维时序信号 | CNN+频谱图、RNN、Transformer | 频率、音调、时序依赖 |
> | 视频 | 时空序列数据 | 3D CNN、RNN+CNN、Video Transformer | 时空特征、动作捕捉 |
嵌入的本质是将离散对象(如单词、图像或整个文档)映射到连续向量空间中的点。嵌入的主要目的是将非数值数据转换为神经网络能够处理的格式。
虽然单词嵌入是最常用的文本嵌入形式,但也存在句子、段落或整篇文档的嵌入。句子和段落嵌入常被用于检索增强生成技术。检索增强生成结合了文本生成与从外部知识库中检索相关信息的过程,这是一种超出本书讨论范围的技术。由于我们希望训练类似于GPT的LLM,这类模型以逐字的方式生成文本,因此本章将重点放在单词嵌入上。
> [!TIP]
>
> **个人思考:** 这里聊一下检索增强技术(RAG),目前已经广泛应用于特定领域的知识问答场景。尽管GPT在文本生成任务中表现强大,但它们依赖的是预训练的知识,这意味着它们的回答依赖于模型在预训练阶段学习到的信息。这种方式导致了几个问题:
>
> + **知识的时效性:** 模型的知识基于它的预训练数据,因此无法获取最新的信息。比如,GPT-3 的知识截止到 2021 年,无法回答最新的事件或发展。
> + **模型大小的限制:** 即使是大型模型,所能存储和运用的知识也是有限的。如果任务涉及特定领域(如医学、法律、科学研究),模型在预训练阶段可能没有涵盖足够的信息。
> + **生成的准确性:** 生成模型可能会凭空编造信息(即“幻觉现象”),导致生成内容不准确或虚假。
>
> 而检索增强技术正是为了解决上述不足,它大致原理为将外部知识库(如文档、数据库、互联网等)进行向量化后存入到向量数据库中。当用户提交一个查询时,首先将这个查询也编码成一个向量,然后去承载外部知识库的向量数据库中检索(检索技术有很多种)与问题相关的信息。检索到的信息被作为额外的上下文信息输入到LLM中,LLM会将这些外部信息与原始输入结合起来,以更准确和丰富的内容生成回答。想要进一步了解RAG技术及其应用,可以参考:[RAG 专区](https://waytoagi.feishu.cn/wiki/PUUfwNkwqielBOkbO5RcjnTQnUd)
生成单词嵌入的算法和框架有很多。其中,Word2Vec是较早且最受欢迎的项目之一。Word2Vec通过预测给定目标词的上下文或反之,训练神经网络架构以生成单词嵌入。Word2Vec的核心思想是,出现在相似上下文中的词通常具有相似的含义。因此,当将单词投影到二维空间进行可视化时,可以看到相似的词汇聚在一起,如图2.3所示。
词嵌入可以具有不同的维度,从一维到数千维。如图2.3所示,我们可以选择二维词嵌入进行可视化。更高的维度可能捕捉到更细微的关系,但代价是计算效率的降低。
虽然我们可以使用预训练模型(例如 Word2Vec)为机器学习模型生成嵌入,但 LLM 通常会生成自己的嵌入,这些嵌入是输入层的一部分,并在训练过程中进行更新。将嵌入作为 LLM 训练的一部分进行优化,而不直接使用 Word2Vec,有一个明确的优势,就是嵌入能够针对特定的任务和数据进行优化。我们将在本章后面实现这样的嵌入层。此外,LLM 还能够创建上下文化的输出嵌入,这一点我们将在第三章中讨论。
高维嵌入在可视化中面临挑战,因为我们的感官感知和常见的图形表示本质上只限于三维或更少的维度,这也是图 2.3 采用二维散点图展示二维嵌入的原因。然而,在处理 LLM 时,我们通常使用的嵌入的维度远高于图 2.3 所示的维度。对于 GPT-2 和 GPT-3,嵌入的大小(通常称为模型隐状态的维度)会根据具体的模型变体和大小而有所不同。这是性能与效率之间的权衡。以具体示例为例,最小的 GPT-2 模型(117M 和 125M 参数)使用 768 维的嵌入大小,而最大的 GPT-3 模型(175B 参数)则使用 12,288 维的嵌入大小。
本章接下来的部分将系统地介绍准备 LLM 使用的嵌入所需的步骤,这些步骤包括将文本拆分为单词、将单词转换为token,以及将token转化为嵌入向量。
## 2.2 文本分词
本节将讨论如何将输入文本拆分为单个token,这是创建 LLM 嵌入所需的预处理步骤。这些token可以是单个单词或特殊字符,包括标点符号,具体如图 2.4 所示。
我们即将用于 LLM 训练的文本数据集是一部由 Edith Wharton 创作的短篇小说《判决》,该作品已在网上公开,因此允许用于 LLM 训练任务。该文本可在 Wikisource 上找到,网址是 [https://en.wikisource.org/wiki/The_Verdict](https://en.wikisource.org/wiki/The_Verdict),您可以将其复制并粘贴到文本文件中。我已将其复制到名为 "the-verdict.txt" 的文本文件中,以便使用 Python 的标准文件读取工具进行加载。
```python
# Listing 2.1 Reading in a short story as text sample into Python
with open("the-verdict.txt", "r", encoding="utf-8") as f:
raw_text = f.read()
print("Total number of character:", len(raw_text))
print(raw_text[:99])
```
另外,您可以在本书的 GitHub 仓库中找到名为 "the-verdict.txt" 的文件,网址是 [https://github.com/rasbt/LLM-from-scratch/tree/main/ch02/01_main-chapter-code](https://github.com/rasbt/LLM-from-scratch/tree/main/ch02/01_main-chapter-code)
便于演示的目的,print命令输出文件的总字符数以及前100个字符。
```
Total number of character: 20479
I HAD always thought Jack Gisburn rather a cheap genius--though a good fellow enough--so
it was no
```
我们的目标是将这篇 20,479 个字符的短篇小说拆分为单词和特殊字符(统称为token),然后在接下来的章节中将这些token转换为 LLM 训练所需的嵌入。
> [!NOTE]
>
> **样本规模**
>
> 请注意,在处理 LLM 时,通常会处理数百万篇文章和数十万本书——也就是几 GB 的文本。然而,为了教学目的,使用像单本书这样的小文本样本就足够了,这样可以阐明文本处理步骤的主要思想,并能够在消费级硬件上合理地运行。
要如何做才能最好地拆分这段文本以获得token列表呢?为此,我们来进行一个小小的探讨,使用 Python 的正则表达式库 re 进行说明。(请注意,您不需要学习或记住任何正则表达式语法,因为在本章后面我们将使用一个预构建的分词器。)
使用一些简单的示例文本,我们可以使用 re.split 命令,按照以下语法拆分文本中的空白字符:
```python
import re
text = "Hello, world. This, is a test."
result = re.split(r'(\s)', text)
print(result)
```
执行结果是一个包含单词、空白和标点符号的列表:
```
['Hello,', ' ', 'world.', ' ', 'This,', ' ', 'is', ' ', 'a', ' ', 'test.']
```
请注意,上述简单的分词方案仅仅用于将示例文本拆分为单个单词,然而有些单词仍然与我们希望单独列出的标点符号相连。我们也无需将所有文本转换为小写字母,因为大写字母有助于 LLM 区分专有名词和普通名词,理解句子结构,并学习生成正确的大写文本。
让我们修改正则表达式,将空白字符(\s)、逗号和句点([,.])单独拆分出来:
```python
result = re.split(r'([,.]|\s)', text)
print(result)
```
我们可以看到,单词和标点符号现在已经成为单独一项,跟我们预期一致:
```
['Hello', ',', '', ' ', 'world', '.', '', ' ', 'This', ',', '', ' ', 'is', ' ', 'a', ' ', 'test', '.', '']
```
一个剩余的小问题是列表仍然包含空白字符。我们可以按如下方式安全地删除这些多余的字符:
```python
result = [item for item in result if item.strip()]
print(result)
```
```
['Hello', ',', 'world', '.', 'This', ',', 'is', 'a', 'test', '.']
```
> [!NOTE]
>
> **关于是否删除空白字符的探讨**
>
> 在开发一个简单的分词器时,是否将空白字符编码为单独的字符,或者直接将其删除,取决于我们的应用和需求。删除空白字符可以减少内存和计算资源的消耗。然而,如果我们训练的模型对文本的确切结构敏感(例如,Python 代码对缩进和空格非常敏感),那么保留空白字符就很有用。在这里,为了简化和缩短分词化输出,我们选择删除空白字符。稍后,我们将切换到一个包含空白字符的分词化方案。
我们上面设计的分词方案在简单的示例文本中表现良好。让我们进一步修改它,使其能够处理其他类型的标点符号,如问号、引号,以及在 Edith Wharton 短篇小说的前 100 个字符中看到的双破折号,还有其他特殊字符:
```python
text = "Hello, world. Is this-- a test?"
result = re.split(r'([,.:;?_!"()\']|--|\s)', text)
result = [item.strip() for item in result if item.strip()]
print(result)
```
执行后输出如下:
```
['Hello', ',', 'world', '.', 'Is', 'this', '--', 'a', 'test', '?']
```
如图 2.5 所示,我们的分词方案现在能够成功处理文本中的各种特殊字符。
现在我们已经有了一个基本的分词器,接下来让我们将其应用于艾迪丝·沃顿的整篇短篇小说:
```python
preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', raw_text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
print(len(preprocessed))
```
上述代码的输出是4690,这是小说的token数量(不包含空白字符)。
让我们检查一下前30个token:
```python
print(preprocessed[:30])
```
生成的输出显示,我们的分词器似乎很好地处理了文本,因为所有单词和特殊字符都被很好地分开了:
```
['I', 'HAD', 'always', 'thought', 'Jack', 'Gisburn', 'rather', 'a', 'cheap', 'genius', '--', 'though', 'a', 'good', 'fellow', 'enough', '--', 'so', 'it', 'was', 'no', 'great', 'surprise', 'to', 'me', 'to', 'hear', 'that', ',', 'in']
```
## 2.3 将 tokens 转换为token IDs
在前一章节中,我们将艾迪丝·华顿的短篇小说分词为单独的token。在本节中,我们将把这些token从字符串转换为整形,以生成所谓的token ID。这一步是将token ID 转换为嵌入向量的中间步骤。
为了将先前生成的token映射到token ID,我们首先需要构建一个词汇表。这个词汇表定义了每个独特单词和特殊字符与唯一整数的映射,如图 2.6 所示。
在前一章节中,我们将艾迪丝·华顿的短篇小说进行分词,并将其存储在名为 preprocessed 的 Python 变量中。现在,让我们创建一个包含所有唯一token的列表,并按字母顺序对其进行排序,以确定词汇表的大小:
```python
all_words = sorted(set(preprocessed))
vocab_size = len(all_words)
print(vocab_size)
```
在通过上述代码确定词汇表的大小为 1,130 后,我们通过以下代码创建词汇表并打印其前 51 个条目以便于说明:
```python
vocab = {token:integer for integer,token in enumerate(all_words)}
for i, item in enumerate(vocab.items()):
print(item)
if i > 50:
break
```
输出如下:
```
('!', 0)
('"', 1)
("'", 2)
...
('Her', 49)
('Hermia', 50)
```
根据输出可知,词汇表包含了与唯一整数标签相关联的单个token。我们接下来的目标是利用这个词汇表,将新文本转换为token ID,如图 2.7 所示。
在本书后面,当我们想将 LLM 的输出从数字转换回文本时,我们还需要一种将token ID 转换为文本的方法。为此,我们可以创建一个词汇表的反向版本,将token ID 映射回相应的文本token。
让我们在 Python 中实现一个完整的分词器类,其中包含一个 encode 方法,该方法负责将文本拆分为token,并通过词汇表进行token字符串到整数(token ID)的映射,以通过词汇表生成token ID。此外,我们还将实现一个 decode 方法,该方法则负责进行整数到字符串的反向映射,将token ID 转换回文本。
该分词器的代码实现如下:
```python
# Listing 2.3 Implementing a simple text tokenizer
class SimpleTokenizerV1:
def __init__(self, vocab):
self.str_to_int = vocab #A
self.int_to_str = {i:s for s,i in vocab.items()} #B
def encode(self, text): #C
preprocessed = re.split(r'([,.?_!"()\']|--|\s)', text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
ids = [self.str_to_int[s] for s in preprocessed]
return ids
def decode(self, ids): #D
text = " ".join([self.int_to_str[i] for i in ids])
text = re.sub(r'\s+([,.?!"()\'])', r'\1', text) #E
return text
#A 将词汇表作为类属性存储,以方便在 encode 和 decode 方法中访问
#B 创建一个反向词汇表,将token ID 映射回原始的文本token
#C 将输入文本转换为token ID
#D 将token ID 还原为文本
#E 在指定的标点符号前去掉空格
```
使用上述的 SimpleTokenizerV1 Python 类,我们现在可以使用现有的词汇表实例化新的分词器对象,并利用这些对象对文本进行编码和解码,如图 2.8 所示。
让我们通过 SimpleTokenizerV1 类实例化一个新的分词器对象,并对艾迪丝·华顿的短篇小说中的一段文本进行分词,以便在实践中进行尝试:
```python
tokenizer = SimpleTokenizerV1(vocab)
text = """"It's the last he painted, you know," Mrs. Gisburn said with pardonable pride."""
ids = tokenizer.encode(text)
print(ids)
```
上面的代码打印出以下token ID:
```
[1, 56, 2, 850, 988, 602, 533, 746, 5, 1126, 596, 5, 1, 67, 7, 38, 851, 1108, 754, 793, 7]
```
接下来,让我们看看能否通过 decode 方法将这些token ID 转换回文本:
```python
print(tokenizer.decode(ids))
```
输出如下:
```
'" It\' s the last he painted, you know," Mrs. Gisburn said with pardonable pride.'
```
根据以上的输出,我们可以看到 decode 方法成功将token ID 转换回了原始文本。
到目前为止,一切都很顺利。我们实现了一个分词器,能够根据训练集中的片段对文本进行分词和去分词。现在让我们将其应用于训练集中未包含的新文本样本:
```python
text = "Hello, do you like tea?"
print(tokenizer.encode(text))
```
执行上述代码将导致以下错误:
```
...
KeyError: 'Hello'
```
问题在于短篇小说《判决》中没有使用“Hello”这个词。因此,它不包含在词汇中。这突显了在处理大型语言模型时,需要考虑大型和多样化的训练集以扩展词汇的必要性。
在下一节中,我们将进一步测试分词器在包含未知词汇的文本上的表现,并且我们还将讨论可以用于在训练期间为LLM提供更多上下文的额外特殊tokens。
## 2.4 添加特殊上下文token
在上一节中,我们实现了一个简单的分词器,并将其应用于训练集中的一段文本。在本节中,我们将修改这个分词器来处理未知单词。
具体来说,我们将修改在前一节中实现的词汇表和分词器类(修改后的类命名为SimpleTokenizerV2),以支持两个新的token:<|unk|> 和 <|endoftext|>,具体见图 2.9。
如图2.9所示,我们可以修改分词器,以便在遇到不在词汇表中的单词时使用一个<|unk|> token。此外,我们还会在不相关的文本之间添加一个特殊的<|endoftext|> token。例如,在对多个独立文档或书籍进行GPT类大语言模型的训练时,通常会在每个文档或书籍之前插入一个token,以连接前一个文本源,如图2.10所示。这有助于大语言模型理解,尽管这些文本源在训练中是连接在一起的,但它们实际上是无关的。
现在,让我们修改词汇表,将这两个特殊token 和 <|endoftext|> 包含在内,方法是将它们添加到我们在上一节中创建的唯一单词列表中:
```python
all_tokens = sorted(list(set(preprocessed)))
all_tokens.extend(["<|endoftext|>", "<|unk|>"])
vocab = {token:integer for integer,token in enumerate(all_tokens)}
print(len(vocab.items()))
```
基于上述打印语句的输出,新词汇表的大小为1161(上一节的词汇表大小为1159)。
为了快速检查,让我们打印更新后词汇表的最后5个条目:
```python
for i, item in enumerate(list(vocab.items())[-5:]):
print(item)
```
执行上述代码,输出结果如下:
```
('younger', 1156)
('your', 1157)
('yourself', 1158)
('<|endoftext|>', 1159)
('<|unk|>', 1160)
```
根据上述代码的输出,我们可以确认这两个新的特殊token确实成功地被纳入了词汇表。接下来,我们相应地调整代码清单2.3中的分词器,如清单2.4所示:
```python
# Listing 2.4 A simple text tokenizer that handles unknown words
class SimpleTokenizerV2:
def __init__(self, vocab):
self.str_to_int = vocab
self.int_to_str = { i:s for s,i in vocab.items()}
def encode(self, text):
preprocessed = re.split(r'([,.?_!"()\']|--|\s)', text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
preprocessed = [item if item in self.str_to_int #A
else "<|unk|>" for item in preprocessed]
ids = [self.str_to_int[s] for s in preprocessed]
return ids
def decode(self, ids):
text = " ".join([self.int_to_str[i] for i in ids])
text = re.sub(r'\s+([,.?!"()\'])', r'\1', text) #B
return text
#A 用 <|unk|> tokens替换未知词汇
#B 在指定标点符号前替换空格
```
与我们在上一节的代码清单 2.3 中实现的 SimpleTokenizerV1 相比,新的 SimpleTokenizerV2 用 <|unk|> token 替换未知词。
```python
text1 = "Hello, do you like tea?"
text2 = "In the sunlit terraces of the palace."
text = " <|endoftext|> ".join((text1, text2))
print(text)
```
输出如下:
```
'Hello, do you like tea? <|endoftext|> In the sunlit terraces of the palace.'
```
接下来,让我们使用在之前的代码清单 2.2 中创建的词汇表,通过 SimpleTokenizerV2 对示例文本进行分词:
```python
tokenizer = SimpleTokenizerV2(vocab)
print(tokenizer.encode(text))
```
这将输出以下token ID列表:
```
[1160, 5, 362, 1155, 642, 1000, 10, 1159, 57, 1013, 981, 1009, 738, 1013, 1160, 7]
```
从上面的内容可以看出,token ID 列表中包含了 1159 ,它对应于 <|endoftext|> 分隔token,以及两个 1160 ,用于表示未知单词。
```python
print(tokenizer.decode(tokenizer.encode(text)))
```
输出如下:
```
'<|unk|>, do you like tea? <|endoftext|> In the sunlit terraces of the <|unk|>.'
```
通过将上面的去token化文本与原始输入文本进行比较,我们可以得知训练数据集,即艾迪丝·华顿的短篇小说《判决》,并不包含单词 "Hello" 和 "palace"。
到目前为止,我们已经讨论了分词作为处理文本输入到 LLM 中的重要步骤。根据不同的 LLM,一些研究人员还考虑其他特殊token,例如以下几种:
+ [BOS](序列开始):这个token表示文本的起始位置,指示 LLM 内容的开始。
+ [EOS](序列结束):这个token位于文本的末尾,在连接多个无关文本时特别有用,类似于 <|endoftext|>。例如,在合并两个不同的维基百科文章或书籍时, [EOS] token指示一篇文章结束和下一篇文章开始。
+ [PAD](填充):在使用大于 1 的批量大小数据集训练 LLM 时,批量可能包含不同长度的文本。为了确保所有文本长度一致,较短的文本会用 [PAD] token进行扩展或填充,直到达到批量中最长文本的长度。
请注意,用于 GPT 模型的分词器不需要上述提到的任何token,而只使用 <|endoftext|> token以简化处理。<|endoftext|> 类似于上面提到的 [EOS] token。此外,<|endoftext|> 也用作填充。然而,正如我们将在后续章节中探讨的那样,在批量输入的训练中,我们通常使用掩码,这意味着我们不会关注填充的token。因此,选择用于填充的特定token变得无关紧要。
> [!TIP]
>
> **个人思考:** 在训练神经网络时,通常会将不同长度的句子或文本批处理为一个 batch 进行并行训练。然而,不同长度的句子需要补齐到同一长度(基于矩阵运算要求形状一致),这时就需要填充 token 来对齐所有序列的长度,使得模型能够有效处理不同长度的输入。掩码其实就是一个标志位,用来告诉大模型哪些位置需要关注,哪些可以忽略,例如考虑以下句子:
>
> + 句子1:"I love NLP."
> + 句子 2:"Transformers are powerful."
> + 句子 3:"GPT is amazing."
>
> 为了将它们放入一个批次,我们需要将它们填充到相同的长度。假设最长句子的长度为 5(token 数量),因此每个句子需要填充到 5 个 token。填充时,GPT 使用 `<|endoftext|>` 作为填充标记。在输入批次时,我们为每个 token 位置创建一个**掩码矩阵**,用来标识哪些位置是有效 token(模型应该关注),哪些是填充 token(模型应该忽略)。假设 `1` 表示有效 token,`0` 表示填充 token,则掩码矩阵如下:
>
> + 句子1(掩码矩阵):`[1, 1, 1, 1, 0]`
> + 句子2(掩码矩阵):`[1, 1, 1, 1, 0]`
> + 句子3(掩码矩阵):`[1, 1, 1, 0, 0]`
>
> 在这个掩码矩阵中,`1` 表示模型会关注的 token,`0` 表示模型会忽略的填充 token。通过这种掩码矩阵,模型知道在计算和训练时哪些 token 是有效内容,哪些 token 是填充部分,无需关注。
此外,用于 GPT 模型的分词器也不使用 <|unk|> 标记来表示词汇表之外的词。相反,GPT 模型采用字节对编码分词器,它将单词分解为子词单元,我们将在下一节中讨论这一点。
## 2.5 字节对编码(Byte pair encoding)
我们在前面的章节中实现了一个简单的分词方案以作说明。本节将介绍一种基于字节对编码(BPE)概念的更复杂的分词方案。BPE分词器曾用于训练大语言模型,如GPT-2、GPT-3以及最初用于 ChatGPT 的 LLM。
由于从零开始实现BPE可能相对复杂,我们将使用一个名为tiktoken的现有Python开源库([https://github.com/openai/tiktoken](https://github.com/openai/tiktoken)),该库基于Rust中的源代码非常高效地实现了BPE算法。与其他Python库类似,我们可以通过Python的pip安装程序从终端安装tiktoken库:
```python
pip install tiktoken
```
本章中的代码基于 tiktoken 0.5.1。您可以使用以下代码来查看您当前安装的版本:
```python
from importlib.metadata import version
import tiktoken
print("tiktoken version:", version("tiktoken"))
```
安装完成后,我们可以按如下方式通过tiktoken实例化BPE分词器:
```python
tokenizer = tiktoken.get_encoding("gpt2")
```
这个分词器的用法类似于我们之前实现的 SimpleTokenizerV2,都是通过 encode 方法使用:
```python
text = "Hello, do you like tea? <|endoftext|> In the sunlit terraces of someunknownPlace."
integers = tokenizer.encode(text, allowed_special={"<|endoftext|>"})
print(integers)
```
上述代码输出以下token ID列表:
```
[15496, 11, 466, 345, 588, 8887, 30, 220, 50256, 554, 262, 4252, 18250, 8812, 2114, 286, 617, 34680, 27271, 13]
```
我们可以使用 decode 方法将token ID 列表转换回文本,类似于我们之前实现的 SimpleTokenizerV2 类的 decode 方法:
```python
strings = tokenizer.decode(integers)
print(strings)
```
上述代码输出以下内容:
```
'Hello, do you like tea? <|endoftext|> In the sunlit terraces of someunknownPlace.'
```
根据上面的token ID 和解码后的文本,我们可以观察到两点:首先,<|endoftext|> token被分配了一个相对较大的token ID,即 50256。实际上,用于训练诸如 GPT-2、GPT-3 以及最初用于训练 ChatGPT 的模型的 BPE 分词器,总词汇表大小为 50,257,其中 <|endoftext|> 被分配了最大的token ID。
其次,上述BPE分词器能够正确编码和解码未知词汇,例如“someunknownPlace”。BPE分词器可以处理任何未知词汇。它是如何在不使用 <|unk|> token的情况下实现这一点的?
BPE背后的算法将不在其预定义词汇表中的单词分解为更小的子词单元甚至单个字符,使其能够处理超出词汇表的单词。因此,得益于BPE算法,如果分词器在分词过程中遇到一个不熟悉的单词,它可以将其表示为一系列子词token或字符,如图2.11所示。
如图 2.11 所示,将未知单词分解为单个字符的能力确保了分词器以及随之训练的 LLM 能够处理任何文本,即使文本中包含训练数据中不存在的单词。
> [!NOTE]
>
> **练习 2.1 未知词的字节对编码**
>
> 尝试使用 tiktoken 库中的 BPE 分词器对未知单词 "Akwirw ier" 进行处理,并输出各个token ID。接着,对此列表中的每个结果整数调用 decode 函数,以重现图 2.11 中的映射。最后,调用token ID 的 decode 方法,检查它是否能够重建原始输入 "Akwirw ier"。
对 BPE 的详细讨论和实现超出了本书的范围,但简而言之,它通过反复合并频繁出现的字符和子词来构建词汇表。例如,BPE 首先将所有单个字符(“a”,“b”,等)添加到词汇表中。在下一阶段,它将经常一起出现的字符组合合并为子词。例如,“d”和“e”可能会合并成子词“de”,这个组合在许多英语单词中很常见,如“define”、“depend”、“made”和“hidden”。这些合并是通过频率截止值来确定的。
> [!TIP]
>
> **个人思考:** 字节对编码是一种基于统计的方法,它会先从整个语料库中找出最常见的字节对(byte pair),然后把这些字节对合并成一个新的单元。让我们用一个具体的示例来描述这个过程:
>
> 假如有句子:“The cat drank the milk because it was hungry”
>
> 1. **初始化:BPE会先将句子中每个字符视为一个单独的token**
>
> ```
> ['T', 'h', 'e', ' ', 'c', 'a', 't', ' ', 'd', 'r', 'a', 'n', 'k', ' ', 't', 'h', 'e', ' ', 'm', 'i', 'l', 'k', ' ', 'b', 'e', 'c', 'a', 'u', 's', 'e', ' ', 'i', 't', ' ', 'w', 'a', 's', ' ', 'h', 'u', 'n', 'g', 'r', 'y']
> ```
>
> 2. **统计最常见的字节对**
>
> BPE算法会在这些token中找到出现频率最高的“字节对”(即相邻的两个字符),然后将其合并为一个新的token。
>
> 例如这里最常见的字节对时('t', 'h'),因为它在单词"the"和"that"中出现频率较高。
>
> 3. **合并字节对**
>
> 根据统计结果,我们将最常见的字节对('t', 'h')合并为一个新的token,其它类似
>
> ```
> ['Th', 'e', ' ', 'c', 'a', 't', ' ', 'dr', 'a', 'nk', ' ', 'th', 'e', ' ', 'm', 'i', 'l', 'k', ' ', 'be', 'c', 'a', 'u', 'se', ' ', 'it', ' ', 'wa', 's', ' ', 'hu', 'n', 'gr', 'y']
> ```
>
> 4. **重复步骤2和3,得到最终的token序列**
>
> ```
> ['The', ' ', 'cat', ' ', 'drank', ' ', 'the', ' ', 'milk', ' ', 'because', ' ', 'it', ' ', 'was', ' ', 'hungry']
> ```
## 2.6 使用滑动窗口进行数据采样
上一节详细介绍了分词步骤以及将字符串分词成token再转换为整数token ID 的过程。在我们最终为 LLM 创建嵌入之前,还要提前做的一件事是生成训练 LLM 所需的输入-目标对。
这些输入-目标对是什么样的呢?正如我们在第一章中所学,LLM通过预测文本中的下一个单词进行预训练,如图2.12所示。
在本节中,我们将实现一个数据加载器,通过滑动窗口方法从训练数据集中提取图 2.12 所示的输入-目标对。
首先,我们将使用前一节中介绍的BPE分词器对我们之前处理的《判决》短篇小说进行分词:
```python
with open("the-verdict.txt", "r", encoding="utf-8") as f:
raw_text = f.read()
enc_text = tokenizer.encode(raw_text)
print(len(enc_text))
```
执行上述代码输出 5145,这表示在训练集上应用BPE分词器后,返回的token总数。
接下来,我们从数据集中移除前50个token以便演示,因为这会在接下来的步骤中产生稍微更有趣的文本段落。
```python
enc_sample = enc_text[50:]
```
创建输入-目标对以进行下一个单词预测任务的最简单和最直观的方法之一是创建两个变量x和y,其中x包含输入token,y包含目标,即输入向右移动1位的结果。
```python
context_size = 4 #A
x = enc_sample[:context_size]
y = enc_sample[1:context_size+1]
print(f"x: {x}")
print(f"y: {y}")
#A 上下文大小决定输入中包含多少个token
```
执行以上代码输出如下:
```
x: [290, 4920, 2241, 287]
y: [4920, 2241, 287, 257]
```
在处理输入和目标(即输入向后移动一个位置)后,我们可以创建如图 2.12 所示的下一个单词预测任务,如下所示:
```python
for i in range(1, context_size+1):
context = enc_sample[:i]
desired = enc_sample[i]
print(context, "---->", desired)
```
执行后输出如下:
```
[290] ----> 4920
[290, 4920] ----> 2241
[290, 4920, 2241] ----> 287
[290, 4920, 2241, 287] ----> 257
```
箭头左侧(---->)的所有内容代表 LLM 将接收到的输入,而箭头右侧的token ID 则表示 LLM 应该预测的目标token ID。
为了演示,我们将重复之前的代码,但将token ID 转换为文本:
```python
for i in range(1, context_size+1):
context = enc_sample[:i]
desired = enc_sample[i]
print(tokenizer.decode(context), "---->", tokenizer.decode([desired]))
```
以下输出展示了输入和输出在文本格式下的样子:
```
and ----> established
and established ----> himself
and established himself ----> in
and established himself in ----> a
```
我们现在已经创建了输入-目标对,可以在接下来的章节中应用于 LLM 的训练。
在我们将token转换为嵌入之前,还有一个任务要完成,正如我们在本章开始时提到的:实现一个高效的数据加载器,该加载器遍历输入数据集并将输入和目标作为 PyTorch 张量返回,这些张量可以视为多维数组。
具体来说,我们的目标是返回两个张量:一个输入张量,包括 LLM 看到的文本,另一个目标张量,包含 LLM 需要预测的目标,如图 2.13 所示。
虽然图2.13展示了字符串格式的token以供说明,但代码实现将直接操作token ID,因为 BPE 分词器的 encode 方法将分词和转换为token ID 的过程合并为了一个步骤。
为了实现高效的数据加载器,我们将使用 PyTorch 内置的 Dataset 和 DataLoader 类。有关安装 PyTorch 的更多信息与指导,请参见附录 A 中的 A.1.3 节,安装 PyTorch。
代码清单 2.5 中展示了数据加载器类的实现细节:
```python
# Listing 2.5 A dataset for batched inputs and targets
import torch
from torch.utils.data import Dataset, DataLoader
class GPTDatasetV1(Dataset):
def __init__(self, txt, tokenizer, max_length, stride):
self.input_ids = []
self.target_ids = []
token_ids = tokenizer.encode(txt) #A
for i in range(0, len(token_ids) - max_length, stride): #B
input_chunk = token_ids[i:i + max_length]
target_chunk = token_ids[i + 1: i + max_length + 1]
self.input_ids.append(torch.tensor(input_chunk))
self.target_ids.append(torch.tensor(target_chunk))
def __len__(self): #C
return len(self.input_ids)
def __getitem__(self, idx): #D
return self.input_ids[idx], self.target_ids[idx]
#A 将整个文本进行分词
#B 使用滑动窗口将书籍分块为最大长度的重叠序列。
#C 返回数据集的总行数
#D 从数据集中返回指定行
```
清单 2.5 中的 GPTDatasetV1 类继承自 PyTorch Dataset 类,定义了如何从数据集中提取单行,其中每行由多个token ID(基于 max_length)组成,并赋值给 input_chunk 张量。target_chunk 张量则包含相应的目标。请继续阅读,以了解将此数据集与 PyTorch DataLoader 结合时返回的数据的样子——这将让我们更清晰的了解运作原理。
以下代码将使用刚创建的 GPTDatasetV1 类,通过 PyTorch DataLoader 以批量方式加载输入:
```python
# Listing 2.6 A data loader to generate batches with input-with pairs
def create_dataloader_v1(txt, batch_size=4, max_length=256,
stride=128, shuffle=True, drop_last=True, num_workers=0):
tokenizer = tiktoken.get_encoding("gpt2") #A
dataset = GPTDatasetV1(txt, tokenizer, max_length, stride) #B
dataloader = DataLoader(
dataset,
batch_size=batch_size,
shuffle=shuffle,
drop_last=drop_last, #C
num_workers=0 #D
)
return dataloader
#A 初始化分词器
#B 创建GPTDatasetV1类
#C drop_last=True会在最后一批次小于指定的batch_size时丢弃该批次,以防止训练期间的损失峰值
#D 用于预处理的CPU进程数量
```
让我们设置 batch_size = 1 和 max_length = 4,观察代码清单 2.5 中的 GPTDatasetV1 类和清单 2.6 中的 create_dataloader_v1 函数如何协同工作:
```python
with open("the-verdict.txt", "r", encoding="utf-8") as f:
raw_text = f.read()
dataloader = create_dataloader_v1(
raw_text, batch_size=1, max_length=4, stride=1, shuffle=False)
data_iter = iter(dataloader) #A
first_batch = next(data_iter)
print(first_batch)
#A 将数据加载器转换为 Python 迭代器,以便通过 Python 的内置 next() 函数获取下一个数据条目。
```
执行这段代码,输出如下:
```
[tensor([[ 40, 367, 2885, 1464]]), tensor([[ 367, 2885, 1464, 1807]])]
```
`first_batch` 变量包含两个张量:第一个张量存储输入token ID,第二个张量存储目标token ID。由于 `max_length` 设置为 4,因此这两个张量各包含 4 个token ID。请注意,输入大小为 4 相对较小,仅用于演示目的。通常,训练 LLM 的输入大小至少为 256。
为了阐明 `stride=1` 的含义,让我们从这个数据集中提取另一个批次:
```python
second_batch = next(data_iter)
print(second_batch)
```
第二个批次的具体内容如下:
```
[tensor([[ 367, 2885, 1464, 1807]]), tensor([[2885, 1464, 1807, 3619]])]
```
如果我们将第一个批次与第二个批次进行比较,可以看到第二个批次的token ID 相较于第一个批次右移了一个位置(例如,第一个批次输入中的第二个 ID 是 367,而它是第二个批次输入的第一个 ID)。步幅设置决定了输入在批次之间移动的位置数,模拟了滑动窗口的方法,如图 2.14 所示。
> [!NOTE]
>
> **练习 2.2 针对数据加载器(Data Loaders)设置不同步幅和上下文大小**
>
> 为了更好地理解数据加载器的工作原理,请尝试使用不同的设置进行测试,例如 `max_length=2` 和 `stride=2`,以及 `max_length=8` 和 `stride=2`。
迄今为止,我们从数据加载器中采样的批次大小都为1,这主要用于说明运作原理。如果你有深度学习的经验,你可能知道,小批次大小在训练时消耗内存较少,但会导致模型更新变得更加困难。就像在常规深度学习中一样,批次大小的设置是一个权衡,它作为超参数需要在训练 LLM 过程中进行实验和调整。
在我们继续本章最后两节之前(最后两节专注于从token ID 创建嵌入向量),先简要了解一下如何使用数据加载器以大于 1 的批次大小进行采样:
```python
dataloader = create_dataloader_v1(raw_text, batch_size=8, max_length=4, stride=4)
data_iter = iter(dataloader)
inputs, targets = next(data_iter)
print("Inputs:\n", inputs)
print("\nTargets:\n", targets)
This prints the following:
Inputs:
tensor([[ 40, 367, 2885, 1464],
[ 1807, 3619, 402, 271],
[10899, 2138, 257,7026],
[15632, 438, 2016, 257],
[ 922, 5891, 1576, 438],
[ 568, 340, 373, 645],
[ 1049, 5975, 284, 502],
[ 284, 3285, 326, 11]])
Targets:
tensor([[ 367, 2885, 1464, 1807],
[ 3619, 402, 271, 10899],
[ 2138, 257, 7026, 15632],
[ 438, 2016, 257, 922],
[ 5891, 1576, 438, 568],
[ 340, 373, 645, 1049],
[ 5975, 284, 502, 284],
[ 3285, 326, 11, 287]])
```
请注意,以上代码将步幅增加到了 4。这是为了全面利用数据集(我们不跳过任何单词),同时避免批次之间的重叠,因为更多的重叠可能会导致过拟合:
在本章的最后两个部分,我们将实现嵌入层,将token ID 转换为连续的向量表示,这些表示将用作 LLM 的输入数据格式。
## 2.7 构建词嵌入层
为 LLM 准备训练集的最后一步是将token ID 转换为嵌入向量,如图 2.15 所示,这将是本章最后两部分的主要内容。
除了图 2.15 中概述的过程外,还需注意的是,我们首先会以随机值初始化这些嵌入权重。这一初始化为 LLM 的学习过程提供了起始点。我们将在第 5 章中优化嵌入权重,作为 LLM 训练的一部分。
对于GPT类大语言模型(LLM)来说,连续向量表示(Embedding)非常重要,原因在于这些模型使用深度神经网络结构,并通过反向传播算法(backpropagation)进行训练。如果你不熟悉神经网络是如何通过反向传播进行训练的,请参阅附录 A 中的 A.4 节,《自动微分简易教程》。
> [!TIP]
>
> **个人思考:** 上面一段描述说的有些笼统,为什么通过反向传播算法训练的大语言模型必须具有Embedding,让我们通过以下几个方面来分析和思考:
>
> 1. **深度神经网络和连续向量表示**
>
> GPT 类模型(以及其他深度神经网络)是基于大量的矩阵运算和数值计算构建的,尤其是神经元之间的连接权重和偏置在训练过程中不断更新。这些运算要求输入的数据是**数值形式的向量**,因为神经网络只能对数值数据进行有效计算,而无法直接处理原始的离散文字数据(如单词、句子)。
>
> + **向量表示: **通过将每个单词、句子或段落转换为连续向量(Embedding),可以在高维空间中表示文本的语义关系。例如,通过词嵌入(如 Word2Vec、GloVe)或上下文嵌入(如 GPT 中的词嵌入层),每个单词都被转换为一个向量,这个向量可以用于神经网络的计算。
>
> 2. **向量嵌入的作用**
>
> 连续向量表示不仅让文本数据可以进入神经网络,还帮助模型捕捉和表示文本之间的语义关系。例如:
>
> + **同义词或相似词**:在向量空间中,相似的单词可以有接近的向量表示。这种语义相似性帮助模型理解上下文,并在生成文本时提供参考。
> + **上下文关系**:GPT 等 LLM 模型不仅依赖单词级别的向量表示,还会考虑句子或段落上下文,形成动态嵌入,从而生成更具连贯性的文本。
>
> 3. **反向传播算法的要求**
>
> 深度神经网络通过**反向传播算法**进行训练,反向传播的本质是利用梯度下降法来更新网络的权重,以最小化损失函数(loss function)。反向传播要求每一层的输入、输出和权重都能够参与梯度计算,而梯度计算只能应用于数值数据。
>
> + **自动微分与梯度计算**:在反向传播中,神经网络会根据损失函数的导数来计算梯度,这个过程依赖于自动微分(automatic differentiation)。为了计算每层的梯度,输入的数据必须是数值形式(即向量),否则无法对离散的文本数据求导。
> + **梯度更新权重**:每次更新网络权重时,神经网络会根据每一层的输入和输出来调整权重,以更好地学习数据的模式。如果输入不是数值形式,就无法实现梯度更新,从而无法通过反向传播训练网络。
让我们通过一个实际示例来说明token ID 到嵌入向量转换的工作原理。假设我们有以下四个输入token,它们的 ID 分别为 2、3、5 和 1:
```python
input_ids = torch.tensor([2, 3, 5, 1])
```
为了简化并起到说明的目的,假设我们有一个只有 6 个单词的小词汇表(而不是 BPE 分词器中的 50,257 个单词),并且我们希望创建大小为 3 的嵌入向量(在 GPT-3 中,嵌入大小为 12,288 维):
```python
vocab_size = 6
output_dim = 3
```
我们可以使用 `vocab_size` 和 `output_dim`在 PyTorch 中实例化一个嵌入层,并将随机种子设置为 123,以便结果可重复:
```python
torch.manual_seed(123)
embedding_layer = torch.nn.Embedding(vocab_size, output_dim)
print(embedding_layer.weight)
```
前面代码示例中的 print 语句输出了嵌入层的基础权重矩阵:
```
Parameter containing:
tensor([[ 0.3374, -0.1778, -0.1690],
[ 0.9178, 1.5810, 1.3010],
[ 1.2753, -0.2010, -0.1606],
[-0.4015, 0.9666, -1.1481],
[-1.1589, 0.3255, -0.6315],
[-2.8400, -0.7849, -1.4096]], requires_grad=True)
```
可以看到,嵌入层的权重矩阵由比较小的随机值组成。这些值将在LLM训练过程中作为LLM优化的一部分被优化,正如我们将在接下来的章节中看到的。此外,权重矩阵有六行三列。嵌入矩阵的每一行代表词汇表中的一个token(每个token都有一个唯一的向量表示),而每一列代表嵌入空间中的一个维度(在这个例子中,嵌入维度为3,即每个token被表示为一个3维向量)。
实例化好嵌入层后,我们可以通过它获取指定token ID的嵌入向量:
```python
print(embedding_layer(torch.tensor([3])))
```
以上代码输出的嵌入向量如下:
```
tensor([[-0.4015, 0.9666, -1.1481]], grad_fn=)
```
如果我们将token ID 3 的嵌入向量与之前的嵌入矩阵进行比较,会发现它与第四行相同(Python 从零开始索引,因此它对应于索引 3 的行)。换句话说,嵌入层本质上是一个查找功能,通过token ID 从嵌入层的权重矩阵中检索行。
> [!NOTE]
>
> **嵌入层与矩阵乘法**
>
> 对于那些熟悉独热编码的人来说,上述嵌入层方法本质上只是实现独热编码后再进行矩阵乘法的一种更高效的方式,相关内容在 GitHub 的补充代码中进行了说明,链接为[https://github.com/rasbt/LLM-from-scratch/tree/main/ch02/03_bonus_embedding-vs-matmul](https://github.com/rasbt/LLM-from-scratch/tree/main/ch02/03_bonus_embedding-vs-matmul)。由于嵌入层只是独热编码和矩阵乘法方法的更高效实现,因此可以视为一个可以通过反向传播进行优化的神经网络层。
之前,我们已经看到如何将单个token ID 转换为三维嵌入向量。现在让我们将其应用于之前定义的所有四个输入 ID(`torch.tensor([2, 3, 5, 1])`):
```python
print(embedding_layer(input_ids))
```
输出是一个4x3 的矩阵:
```
tensor([[ 1.2753, -0.2010, -0.1606],
[-0.4015, 0.9666, -1.1481],
[-2.8400, -0.7849, -1.4096],
[ 0.9178, 1.5810, 1.3010]], grad_fn=)
```
输出矩阵中的每一行都是通过从嵌入权重矩阵进行查找操作获得的,如图2.16所示。
本节介绍了如何从token ID 创建嵌入向量。本章的下一节也是最后一节将对这些嵌入向量进行小的修改,以编码文本中token的位置信息。
## 2.8 位置编码
在上一节中,我们将token ID 转换为连续的向量表示,即所谓的token嵌入。原则上,这适合作为 LLM 的输入。然而,LLM的一个小缺点是它们的自注意力机制(将在第3章详细介绍)对序列中token的位置或顺序没有概念。
之前引入的嵌入层的工作方式是,无论token ID 在输入序列中的位置如何,相同的token ID 始终映射到相同的向量表示,如图 2.17 所示。
从原则上讲,确定性的、与位置无关的token ID 嵌入对于可重复性是有益的。然而,由于LLM的自注意力机制本身也是与位置无关的,因此向LLM注入额外的位置信息是有帮助的。
绝对位置嵌入与序列中的特定位置直接相关。对于输入序列中的每个位置,都会将一个唯一的绝对位置嵌入向量添加到token的嵌入向量中,以传达其确切位置。例如,第一个token将具有特定的位置嵌入,第二个token将具有另一个不同的嵌入,依此类推,如图2.18所示。
与关注token在序列中的绝对位置不同,相对位置嵌入强调的是token之间的相对位置或距离。这意味着模型学习的是“相隔多远”的关系,而不是“在什么确切位置”。这样的优势在于,即使模型在训练时没有接触过不同的长度,它也可以更好地适应各种长度的序列。
这两种类型的位置嵌入旨在增强 LLM 理解token顺序与关系的能力,从而确保在预测时能对上下文具有更准确的感知。选择哪种类型的位置嵌入通常取决于特定的应用和所处理数据的性质。
OpenAI 的 GPT 模型使用绝对位置嵌入,这些嵌入在训练过程中进行优化,而不是像原始 Transformer 模型中的位置编码那样是固定或预定义的。这个优化过程属于模型训练的一部分,我们将在本书后面的章节中实现。目前,让我们创建初始位置嵌入,以便为接下来的章节准备 LLM 输入。
之前,我们在本章中专注于非常小的嵌入大小以便于说明。我们现在考虑更现实和有用的嵌入大小,并将输入token编码为256维的向量表示。这比原始的GPT-3模型使用的要小(在GPT-3中,嵌入大小为12,288维),但对于实验仍然是合理的。此外,我们假设token ID 是由我们之前实现的BPE分词器创建的,该分词器的词汇量为50,257:
```python
vocab_size = 50257
output_dim = 256
token_embedding_layer = torch.nn.Embedding(vocab_size, output_dim)
```
使用上面的 `token_embedding_layer`,如果我们从数据加载器中采样数据,我们将每个批次中的每个token嵌入到一个 256 维的向量中。如果我们的批次大小为 8,每个批次有四个token,那么结果将是一个形状为 8 x 4 x 256 的张量。
首先,让我们实例化 2.6 节中创建的数据加载器,使用滑动窗口进行数据采样:
```python
max_length = 4
dataloader = create_dataloader_v1(
raw_text, batch_size=8, max_length=max_length, stride=max_length, shuffle=False)
data_iter = iter(dataloader)
inputs, targets = next(data_iter)
print("Token IDs:\n", inputs)
print("\nInputs shape:\n", inputs.shape)
```
输出如下:
```
Token IDs:
tensor([[ 40, 367, 2885, 1464],
[ 1807, 3619, 402, 271],
[10899, 2138, 257, 7026],
[15632, 438, 2016, 257],
[ 922, 5891, 1576, 438],
[ 568, 340, 373, 645],
[ 1049, 5975, 284, 502],
[ 284, 3285, 326, 11]])
Inputs shape:
torch.Size([8, 4])
```
我们可以看到,tokenID张量是8x4维的,这意味着数据批次由8个文本样本组成,每个样本有4个token。
现在,让我们使用嵌入层将这些token ID 转换为 256 维的向量:
```python
token_embeddings = token_embedding_layer(inputs)
print(token_embeddings.shape)
```
输出如下:
```
torch.Size([8, 4, 256])
```
从 8x4x256 维的张量输出中,我们可以看到,每个token ID 现在被嵌入为一个 256 维的向量。
对于 GPT 模型所使用的绝对嵌入方法,我们只需创建另一个嵌入层,其维度与 token_embedding_layer 的维度相同:
```python
context_length = max_length
pos_embedding_layer = torch.nn.Embedding(context_length, output_dim)
pos_embeddings = pos_embedding_layer(torch.arange(context_length))
print(pos_embeddings.shape)
```
如前面的代码所示, pos_embeddings 的输入通常是一个占位符向量torch.arange(context_length),它包含一个从0到最大输入长度-1的数字序列。context_length 是一个表示LLM支持的输入大小的变量。在这里,我们设置它与输入文本的最大长度相同。在实际应用中,输入文本可能会超过支持的上下文长度,此时我们需要对文本进行截断。
上述代码输出结果如下:
```
torch.Size([4, 256])
```
正如我们所见,位置嵌入张量由四个 256 维向量组成。我们现在可以将这些直接添加到token嵌入中,在每个批次中,PyTorch 会将 4x256 维的 pos_embeddings 张量添加到每个 4x256 维的token嵌入张量中:
```python
input_embeddings = token_embeddings + pos_embeddings
print(input_embeddings.shape)
```
```
torch.Size([8, 4, 256])
```
我们创建的 input_embeddings,如图 2.19 所示,现在可作为LLM的核心模块的输入嵌入。我们将在第3章开始实现这些模块。
## 2.9 本章摘要
+ LLM 需要将文本数据转换为数值向量,这称之为嵌入,因为它们无法处理原始文本。嵌入将离散数据(如单词或图像)转化为连续的向量空间,从而使其能够与神经网络操作兼容。
+ 作为第一步,原始文本被分解为token,这些token可以是单词或字符。然后,这些token被转换为整数表示,称为token ID。
+ 可以添加特殊token,如 <|unk|> 和 <|endoftext|>,以增强模型的理解能力,并处理各种上下文,例如未知单词或无关文本之间的边界分隔。
+ 用于像 GPT-2 和 GPT-3 这样的 LLM 的字节对编码(BPE)分词器,可以通过将未知单词分解为子词单元或单个字符,高效地处理这些单词。
+ 我们在分词后的文本数据上采用滑动窗口方法,以生成用于 LLM 训练的输入-目标对。
+ 在 PyTorch 中,嵌入层作为一种查找操作,用于检索与token ID 对应的向量。生成的嵌入向量提供了token的连续表示,这在训练像 LLM 这样的深度学习模型时至关重要。
+ 虽然token嵌入为每个token提供了一致的向量表示,但它们并没有考虑token在序列中的位置。为了解决这个问题,存在两种主要类型的位置嵌入:绝对位置嵌入和相对位置嵌入。OpenAI 的 GPT 模型采用绝对位置嵌入,这些位置嵌入向量会与token嵌入向量相加,并在模型训练过程中进行优化。
## /cn-Book/3.å®Âç°注æÂÂÃ¥ÂÂæÂºå¶.md
本章涵盖以下内容:
+ **探讨在神经网络中使用注意力机制的原因**
+ **介绍一个基本的自注意力框架,并逐步深入到改进的自注意力机制**
+ **实现一个因果注意力模块,使 LLM 能够一次生成一个token**
+ **使用 dropout 随机掩盖部分注意力权重,以减少过拟合**
-----
- [3.1 长序列建模的问题](#31-长序列建模的问题)
- [3.2 通过注意力机制捕捉数据依赖关系](#32-通过注意力机制捕捉数据依赖关系)
- [3.3 通过自注意力机制关注输入的不同部分](#33-通过自注意力机制关注输入的不同部分)
- [3.3.1 一种不含可训练权重的简化自注意力机制。](#331-一种不含可训练权重的简化自注意力机制)
- [3.3.2 为所有输入的 token 计算注意力权重](#332-为所有输入的-token-计算注意力权重)
- [3.4 实现带有可训练权重的自注意力机制](#34-实现带有可训练权重的自注意力机制)
- [3.4.1 逐步计算注意力权重](#341-逐步计算注意力权重)
- [3.4.2 实现一个简洁的自注意力机制 Python 类](#342-实现一个简洁的自注意力机制-python-类)
- [3.5 使用因果注意力机制来屏蔽后续词](#35-使用因果注意力机制来屏蔽后续词)
- [3.5.1 应用因果注意力掩码](#351-应用因果注意力掩码)
- [3.5.2 使用 dropout 遮掩额外的注意力权重](#352-使用-dropout-遮掩额外的注意力权重)
- [3.5.3 实现一个简洁的因果注意力类](#353-实现一个简洁的因果注意力类)
- [3.6 从单头注意力扩展到多头注意力](#36-从单头注意力扩展到多头注意力)
- [3.6.1 堆叠多层单头注意力层](#361-堆叠多层单头注意力层)
- [3.6.2 通过权重分割实现多头注意力机制](#362-通过权重分割实现多头注意力机制)
- [3.7 本章摘要](#37-本章摘要)
-----
在上一章中,我们学习了如何准备输入文本以训练 LLM。这包括将文本拆分为单个单词和子词token,这些token可以被编码为向量,即所谓的嵌入,以供 LLM 使用。
在本章中,我们将关注 LLM 架构中的重要组成部分,即注意力机制,如图 3.1 所示。
注意力机制是一个复杂的话题,因此我们将专门用一整章来讨论它。我们将注意力机制作为独立模块来研究,重点关注其内部的工作原理。在下一章中,我们将编写与自注意力机制相关的 LLM 的其他部分,以观察其实际运作并创建一个生成文本的模型。
本章中,我们将实现四种不同的注意力机制变体,如图 3.2 所示。
图 3.2 中展示的这些不同的注意力变体是逐步构建的,其目标是在本章末尾实现一个简单且高效的多头注意力机制,以便在下一章中可以将其整合到我们将编写的 LLM 架构中。
## 3.1 长序列建模的问题
在深入了解自注意力机制之前(这是大语言模型的核心),让我们先探讨一下缺乏注意力机制的架构存在哪些问题(这些架构在大语言模型之前已经存在)。假设我们想要开发一个将一种语言翻译成另一种语言的翻译模型。如图 3.3 所示,我们无法简单地逐词翻译文本,因为源语言和目标语言的语法结构往往存在差异。
为了解决逐词翻译的局限性,通常使用包含两个子模块的深度神经网络,即所谓的编码器(encoder)和解码器(decoder)。编码器的任务是先读取并处理整个文本,然后解码器生成翻译后的文本。
在第 1 章(1.4 节,使用 LLM 进行不同任务)介绍 Transformer 架构时,我们已经简要讨论过编码器-解码器网络。在 Transformer 出现之前,循环神经网络(RNN)是最流行的用于语言翻译的编码器-解码器架构。
**循环神经网络(RNN)**是一种神经网络类型,其中前一步的输出会作为当前步骤的输入,使其非常适合处理像文本这样的序列数据。如果您不熟悉 RNN 的工作原理,不必担心,您无需了解 RNN 的详细机制也可以参与这里的讨论;这一节学习的重点更多是编码器-解码器架构的总体概念。
在编码器-解码器架构的 RNN 网络中,输入文本被输入到编码器中,编码器按顺序处理文本内容。在每个步骤中,编码器会更新其隐状态(即隐藏层的内部值),试图在最终的隐状态中捕捉整个输入句子的含义,如图 3.4 所示。随后,解码器使用该最终隐状态来开始逐词生成翻译句子。解码器在每一步也会更新其隐状态,用于携带生成下一个词所需的上下文信息。
尽管我们不需要深入了解这些编码器-解码器架构的 RNN 的内部工作原理,但这里的关键思想在于,编码器部分将整个输入文本处理为一个隐藏状态(记忆单元)。解码器随后使用该隐藏状态生成输出。您可以将这个隐藏状态视为一个嵌入向量,这是我们在第 2 章中已讨论过的概念。
编码器-解码器架构的 RNN 的一个重大问题和限制在于,**在解码阶段 RNN 无法直接访问编码器的早期隐藏状态**。因此,它只能依赖当前隐藏状态来封装所有相关信息。这种设计可能导致上下文信息的丢失,特别是在依赖关系较长的复杂句子中,这一问题尤为突出。
对于不熟悉 RNN 的读者,不必深入理解或学习这种架构,因为本书中不会使用它。本节的重点是,编码器-解码器 RNN 存在一个缺点,这一缺点促使了注意力机制的设计。
> [!TIP]
>
> **个人思考:** 虽然本书没有涉及对RNN的过多讨论,但了解从RNN到注意力机制的技术变迁对于核心内容的理解至关重要。让我们通过一个具体的示例来理解这种技术变迁:
>
> 1. **RNN的局限性**
>
> 假设我们有一个长句子:“The cat, who was sitting on the windowsill, jumped down because it saw a bird flying outside the window.”
>
> 假设任务是预测句子最后的内容,即要理解“it”指的是“the cat”而不是“the windowsill”或其他内容。对于 RNN 来说,这个任务是有难度的,原因如下:
>
> + **长距离依赖问题**:在 RNN 中,每个新输入的词会被依次传递到下一个时间步。随着句子长度增加,模型的隐状态会不断被更新,但早期信息(如“the cat”)会在层层传播中逐渐消失。因此,模型可能无法在“it”出现时有效地记住“the cat”是“it”的指代对象。
> + **梯度消失问题**:RNN 在反向传播中的梯度会随着时间步的增加逐渐减小,这种“梯度消失”使得模型很难在长句中保持信息的准确传播,从而难以捕捉到长距离的语义关联。
>
> 2. **注意力机制的解决方法**
>
> 为了弥补 RNN 的这些不足,**注意力机制**被引入。它的关键思想是**在处理每个词时,不仅依赖于最后的隐藏状态,而是允许模型直接关注序列中的所有词**。这样,即使是较远的词也能在模型计算当前词的语义时直接参与。
>
> 在上例中,注意力机制如何帮助模型理解“it”指代“the cat”呢?
>
> + **注意力机制的工作原理**:当模型处理“it”时,注意力机制会将“it”与整个句子中的其他词进行相似度计算,判断“it”应该关注哪些词。
> + 由于“the cat”与“it”在语义上更相关,注意力机制会为“the cat”分配较高的权重,而其他词(如“windowsill”或“down”)则获得较低的权重。
> + **信息的直接引用**:通过注意力机制,模型可以跳过中间步骤,直接将“it”与“the cat”关联,而不需要依赖所有的中间隐藏状态。
>
> 3. **示例中的注意力矩阵**
>
> 假设使用一个简单的注意力矩阵,模型在处理“it”时,给每个词的权重可能如下(至于如何计算这些权重值后文会详细介绍):
>
> | 词 | The | cat | who | was | sitting | ... | it | saw | bird | flying | ... | window |
> | -------- | ---- | ---- | ---- | ---- | ------- | ---- | ---- | ---- | ---- | ------ | ---- | ------ |
> | **权重** | 0.1 | 0.3 | 0.05 | 0.05 | 0.05 | ... | 0.4 | 0.05 | 0.02 | 0.01 | ... | 0.02 |
>
> 在这个注意力矩阵中,可以看到**“it”对“the cat”有较高的关注权重(0.3),而对其他词的关注权重较低**。这种直接的关注能力让模型能够高效捕捉长距离依赖关系,理解“it”与“the cat”的语义关联。
## 3.2 通过注意力机制捕捉数据依赖关系
在 Transformer 架构的大语言模型(LLM)出现之前,通常会使用循环神经网络(RNN)来完成语言建模任务,例如语言翻译。RNN 对于翻译短句表现良好,但在处理长文本时效果不佳,因为它们无法直接访问输入序列中的前面词语。
这一方法的一个主要缺陷在于,RNN 必须将整个编码后的输入信息存储在一个隐藏状态中,然后再将其传递给解码器,如上一节的图 3.4 所示。
因此,研究人员在 2014 年为 RNN 开发了所谓的 Bahdanau 注意力机制(该机制以论文的第一作者命名)。该机制对编码器-解码器架构的 RNN 进行了改进,使得解码器在每个解码步骤可以选择性地访问输入序列的不同部分,如图 3.5 所示。
有趣的是,仅仅三年后,研究人员发现构建用于自然语言处理的深度神经网络并不需要 RNN 结构,随后提出了基于自注意力机制的原始 Transformer 架构(在第 1 章中讨论),其灵感来自 Bahdanau 提出的注意力机制。
自注意力机制是一种允许输入序列中的每个位置在计算序列表示时关注同一序列中所有位置的机制。自注意力机制是基于Transformer架构的当代大语言模型(如GPT系列模型)的关键组成部分。
本章将重点讲解并实现 GPT 类模型中使用的自注意力机制,如图 3.6 所示。在下一章中,我们将继续编码 LLM 的其它部分。
## 3.3 通过自注意力机制关注输入的不同部分
现在我们将深入了解自注意力机制的内部工作原理,并从零开始学习如何实现它。自注意力机制是基于 Transformer 架构的所有大语言模型的核心。需要注意的是,这一部分内容可能需要大量的专注与投入(无双关含义),但一旦掌握了它的基本原理,你就攻克了本书及大语言模型实现中最困难的部分之一。
> [!NOTE]
>
> **“自我”在自注意力机制中的含义**
>
> 在自注意力机制中,“self”指的是该机制通过关联同一输入序列中的不同位置来计算注意力权重的能力。它评估并学习输入内部各部分之间的关系和依赖性,例如句子中的单词或图像中的像素。这与传统注意力机制不同,传统机制关注的是两个不同序列间的关系,例如序列到序列模型中,注意力可能存在于输入序列和输出序列之间,这一点在图 3.5 中有示例说明。
由于自注意力机制对于初次接触的读者可能显得较为复杂,我们将在下一小节中首先介绍一个简化版的自注意力机制。随后,在第 3.4 节中,我们将实现带有可训练权重的自注意力机制,这种机制被用于大语言模型(LLM)中。
### 3.3.1 一种不含可训练权重的简化自注意力机制。
在本节中,我们实现了一个简化的自注意力机制版本,没有包含任何可训练的权重,如图 3.7 所示。本节的目标是先介绍自注意力机制中的一些关键概念,然后在 3.4 节引入可训练的权重。
图 3.7 显示了一个输入序列,记作 x,由 T 个元素组成,表示为 x(1) 到 x(T)。该序列通常代表文本,例如一个句子,并且该文本已被转换为 token 嵌入(不记得嵌入概念的请回顾第 2 章)。
举例来说,假设输入文本为 “Your journey starts with one step”。在这个例子中,序列中的每个元素(如 x(1))对应一个 `d` 维的嵌入向量,用于表示特定的 token,例如 “Your”。在图 3.7 中,这些输入向量显示为 3 维的嵌入向量。
在自注意力机制中,我们的目标是为输入序列中的每个元素 x(i) 计算其对应的上下文向量 z(i) 。上下文向量可以被解释为一种增强的嵌入向量(`别着急,后文会解释`)。
为了说明这个概念,我们聚焦于第二个输入元素 x(2) 的嵌入向量(对应于词 "journey")以及相应的上下文向量 z(2),如图 3.7 底部所示。这个增强的上下文向量 z(2) 也是一个嵌入向量,包含了关于 x(2) 以及序列中所有其他输入元素 x(1) 到 x(T) 的语义信息。
在自注意力机制中,上下文向量起着关键作用。它们的目的是通过整合序列中所有其他元素的信息(如同一个句子中的其他词),为输入序列中的每个元素创建丰富的表示,正如图 3.7 所示。这对大语言模型至关重要,因为模型需要理解句子中各个词之间的关系和关联性。之后的章节中,我们将添加可训练的权重,以帮助大语言模型学习构建这些上下文向量,用于执行生成下一个词的任务。
在本节中,我们将实现一个简化的自注意力机制,以逐步计算注意力权重和由此生成的上下文向量。
请考虑以下输入句子,该句子已经根据第 2 章的讨论转换为三维向量。为了便于说明和展示,我们选择了较小的嵌入维度,以确保句子在页面上可以完整地展示。
```python
import torch
inputs = torch.tensor(
[[0.43, 0.15, 0.89], # Your (x^1)
[0.55, 0.87, 0.66], # journey (x^2)
[0.57, 0.85, 0.64], # starts (x^3)
[0.22, 0.58, 0.33], # with (x^4)
[0.77, 0.25, 0.10], # one (x^5)
[0.05, 0.80, 0.55]] # step (x^6)
```
实现自注意力机制的第一步是计算中间值 **ω**,即注意力得分,如图 3.8 所示。(请注意,图 3.8 中展示的输入张量值是截断版的,例如,由于空间限制,0.87 被截断为 0.8。在此截断版中,单词 "journey" 和 "starts" 的嵌入向量可能会由于随机因素而看起来相似)。
图 3.8 展示了如何计算查询 token 与每个输入 token 之间的中间注意力得分。我们通过计算查询 x(2) 与每个其他输入 token 的点积来确定这些得分:
```python
query = inputs[1] #A
attn_scores_2 = torch.empty(inputs.shape[0])
for i, x_i in enumerate(inputs):
attn_scores_2[i] = torch.dot(x_i, query)
print(attn_scores_2)
#A 第二个输入 token 用作查询向量
```
> [!TIP]
>
> **个人思考:** 这里对于注意力得分的计算描述的比较笼统,仅仅说明了将当前的输入Token向量与其它Token的向量进行点积运算计算注意力得分,实际上,每个输入Token会先通过权重矩阵W分别计算出它的Q、K、V三个向量,这三个向量的定义如下:
>
> + **Q向量(查询向量)**:查询向量代表了这个词在寻找相关信息时提出的问题
> + **K向量(键向量)**:键向量代表了一个单词的特征,或者说是这个单词如何"展示"自己,以便其它单词可以与它进行匹配
> + **V向量(值向量)**:值向量携带的是这个单词的具体信息,也就是当一个单词被"注意到"时,它提供给关注者的内容
>
> **更通俗的理解:** 想象我们在图书馆寻找一本书(`Q向量`),我们知道要找的主题(`Q向量`),于是查询目录(`K向量`),目录告诉我哪本书涉及这个主题,最终我找到这本书并阅读内容(`V向量`),获取了我需要的信息。
>
> 具体生成Q、K、V向量的方式主要通过线性变换:
>
> ```python
> Q1 = W_Q * (E1 + Pos1)
> K1 = W_K * (E1 + Pos1)
> V1 = W_V * (E1 + Pos1)
> ```
>
> 依次类推,为所有token生成`Q`,`K`,`V`向量,其中`W_Q`,`W_K`和`W_V`是Transformer训练出的权重(每一层不同)
>
> 针对每一个目标token,Transformer会计算它的 `Q向量` 与其它所有的token的 `K向量` 的点积,以确定每个词对当前词的重要性(即注意力分数)
>
> 假如有句子:“The cat drank the milk because it was hungry”
>
> 例如对于词 `cat` 的 `Q向量 Q_cat`,模型会计算:
>
> + `score_cat_the = Q_cat · K_the` --- 与`the`的语义相关度
> + `score_cat_drank = Q_cat · K_drank` --- 与 `drank` 的语义相关度
> + `score_cat_it = Q_cat · K_it` --- 与 `it` 的语义相关度
> + 依此类推,得到`cat`与句子中其它所有token的注意力分数 `[score_cat_the、score_cat_drank、socre_cat_it、……]`
计算得到的注意力得分如下:
```python
tensor([0.9544, 1.4950, 1.4754, 0.8434, 0.7070, 1.0865])
```
> [!NOTE]
>
> **理解点积**
>
> 点积运算本质上是一种将两个向量按元素相乘后再求和的简单方式,我们可以如下演示:
>
> ```python
> res = 0.
> for idx, element in enumerate(inputs[0]):
> res += inputs[0][idx] * query[idx]
> print(res)
> print(torch.dot(inputs[0], query))
> ```
>
> 输出结果确认,逐元素相乘的和与点积的结果相同。
>
> ```python
> tensor(0.9544)
> tensor(0.9544)
> ```
>
> 除了将点积运算视为结合两个向量并产生标量结果的数学工具之外,点积也是一种相似度的衡量方法,因为它量化了两个向量的对齐程度:较高的点积值表示向量之间有更高的对齐程度或相似度。在自注意力机制的背景下,点积决定了序列中元素之间的关注程度:点积值越高,两个元素之间的相似度和注意力得分就越高。
如图 3.9 所示,接下来,我们对先前计算的每个注意力分数进行归一化。
图3.9中所示的归一化的主要目的是使注意力权重之和为 1。这种归一化是一种有助于解释和保持LLM训练稳定性的惯例。以下是一种实现此归一化步骤的简单方法:
```python
attn_weights_2_tmp = attn_scores_2 / attn_scores_2.sum()
print("Attention weights:", attn_weights_2_tmp)
print("Sum:", attn_weights_2_tmp.sum())
```
如输出所示,现在注意力权重的总和为 1:
```python
Attention weights: tensor([0.1455, 0.2278, 0.2249, 0.1285, 0.1077, 0.1656])
Sum: tensor(1.0000)
```
在实践中,更常见且更推荐使用 softmax 函数来进行归一化。这种方法更擅长处理极端值,并且在训练过程中提供了更有利的梯度特性。以下是用于归一化注意力分数的 softmax 函数的基础实现。
```python
def softmax_naive(x):
return torch.exp(x) / torch.exp(x).sum(dim=0)
attn_weights_2_naive = softmax_naive(attn_scores_2)
print("Attention weights:", attn_weights_2_naive)
print("Sum:", attn_weights_2_naive.sum())
```
从输出中可以看到,softmax 函数可以实现注意力权重的归一化,使它们的总和为 1:
```python
Attention weights: tensor([0.1385, 0.2379, 0.2333, 0.1240, 0.1082, 0.1581])
Sum: tensor(1.)
```
此外,softmax 函数确保注意力权重始终为正值。这使得输出可以被解释为概率或相对重要性,其中较高的权重表示更重要。
注意,这种简单的 softmax 实现(softmax_naive)在处理较大或较小的输入值时,可能会遇到数值不稳定性问题,例如上溢或下溢。因此,实际操作中,建议使用 PyTorch 的 softmax 实现,它经过了充分的性能优化:
```python
attn_weights_2 = torch.softmax(attn_scores_2, dim=0)
print("Attention weights:", attn_weights_2)
print("Sum:", attn_weights_2.sum())
```
可以看到,它与我们之前实现的 `softmax_naive` 函数产生的结果相同。
```python
Attention weights: tensor([0.1385, 0.2379, 0.2333, 0.1240, 0.1082, 0.1581])
Sum: tensor(1.)
```
> [!TIP]
>
> **个人思考:** 这里稍微延伸探讨一下`Softmax`, 它是一种常用的激活函数,尤其在神经网络的分类任务中被广泛使用。它的作用是将一个任意的实数向量转换为一个概率分布,且所有元素的概率之和为 1。下面通过例子来说明 softmax 的原理、好处,以及它在神经网络中的使用原因。
>
> 1. **Softmax 的原理**
>
> Softmax 函数的公式如下:
>
> $$ \text{softmax}\left(z_{i}\right)=\frac{e^{z_{i}}}{\sum_{j} e^{z_{j}}} $$
>
> 其中zi是输入的每个分数(即未激活的原始值),e 是自然对数的底。这个公式的作用是将输入向量中的每个元素转换为一个概率值,且所有值的和为 1。
>
> 2. **Softmax 的好处**
>
> + **归一化输出为概率**:Softmax 将输出转换为 0 到 1 之间的概率,且所有类别的概率之和为 1,方便解释结果。例如,在分类任务中,输出可以直接表示模型对各类别的信心。
> + **平滑和放大效果**:Softmax 不仅能归一化,还具有平滑和放大效果。较大的输入值会被放大,较小的输入值会被抑制,从而增强模型对最优类别的区分。
> + **支持多分类问题**:与 sigmoid 不同,Softmax 适用于多类别分类问题。它可以输出每个类别的概率,使得模型可以处理多分类任务。
>
> 3. **神经网络为什么喜欢使用 Softmax**
>
> 在神经网络中,特别是分类模型(如图像分类、文本分类)中,Softmax 层通常用作最后一层输出。原因包括:
>
> + **便于优化**:在分类任务中,Softmax 输出的概率分布可与真实的标签概率进行比较,从而计算交叉熵损失。交叉熵损失的梯度较为稳定,便于模型的优化。
> + **概率解释**:Softmax 输出可以解释为“模型对每个类别的信心”,使得输出直观可理解。
> + **与交叉熵的结合**:Softmax 与交叉熵损失函数结合效果特别好,可以直接将模型预测的概率分布与真实标签比较,从而更快收敛,效果更好。
现在我们已经计算出了归一化的注意力权重,接下来可以执行图 3.10 所示的最后一步:通过将嵌入后的输入 token x(i) 与相应的注意力权重相乘,再将所得向量求和来计算上下文向量 z(2)。
如图 3.10 所示,上下文向量 z(2) 是所有输入向量的加权和。其计算方法为将每个输入向量与对应的注意力权重相乘后相加。
```python
query = inputs[1] # 2nd input token is the query
context_vec_2 = torch.zeros(query.shape)
for i,x_i in enumerate(inputs):
context_vec_2 += attn_weights_2[i]*x_i
print(context_vec_2)
```
结算结果如下:
```python
tensor([0.4419, 0.6515, 0.5683])
```
在接下来的章节,我们将把串行计算上下文向量的过程优化为并行计算所有输入token的上下文向量。
### 3.3.2 为所有输入的 token 计算注意力权重
在前一节中,我们计算了第二个输入元素的注意力权重和上下文向量(如图 3.11 中的高亮行所示)。现在,我们将扩展该计算,以对所有输入计算注意力权重和上下文向量。
我们沿用之前的三个步骤(如图 3.12 所示),只是对代码做了一些修改,用于计算所有的上下文向量,而不仅仅是第二个上下文向量 z(2)。
如图 3.12 所示,在第 1 步中,我们添加了一个额外的 for 循环,用于计算所有输入对之间的点积。
```python
attn_scores = torch.empty(6, 6)
for i, x_i in enumerate(inputs):
for j, x_j in enumerate(inputs):
attn_scores[i, j] = torch.dot(x_i, x_j)
print(attn_scores)
```
计算得到的注意力分数集合如下:
```python
tensor([[0.9995, 0.9544, 0.9422, 0.4753, 0.4576, 0.6310],
[0.9544, 1.4950, 1.4754, 0.8434, 0.7070, 1.0865],
[0.9422, 1.4754, 1.4570, 0.8296, 0.7154, 1.0605],
[0.4753, 0.8434, 0.8296, 0.4937, 0.3474, 0.6565],
[0.4576, 0.7070, 0.7154, 0.3474, 0.6654, 0.2935],
[0.6310, 1.0865, 1.0605, 0.6565, 0.2935, 0.9450]])
```
以上张量中的每个元素都表示每对输入之间的注意力得分(正如图 3.11 中所示)。请注意,图 3.11 中的值已进行了归一化,因此它们与以上张量中的未经归一化的注意力得分不同。我们稍后会处理归一化。
在上述代码中,我们使用了 Python 中的 for 循环来计算所有输入对的注意力得分。然而,for 循环通常较慢,我们可以通过矩阵乘法实现相同的结果。
```python
attn_scores = inputs @ inputs.T
print(attn_scores)
```
可以看到,结果与之前一致:
```python
tensor([[0.9995, 0.9544, 0.9422, 0.4753, 0.4576, 0.6310],
[0.9544, 1.4950, 1.4754, 0.8434, 0.7070, 1.0865],
[0.9422, 1.4754, 1.4570, 0.8296, 0.7154, 1.0605],
[0.4753, 0.8434, 0.8296, 0.4937, 0.3474, 0.6565],
[0.4576, 0.7070, 0.7154, 0.3474, 0.6654, 0.2935],
[0.6310, 1.0865, 1.0605, 0.6565, 0.2935, 0.9450]])
```
接下来开始执行步骤 2(如图 3.12 所示),我们现在对每一行进行归一化处理,使得每一行的值之和为 1。
```python
attn_weights = torch.softmax(attn_scores, dim=-1)
print(attn_weights)
```
执行上述代码返回的注意力权重张量与图 3.10 中显示的数值一致:
```python
tensor([[0.2098, 0.2006, 0.1981, 0.1242, 0.1220, 0.1452],
[0.1385, 0.2379, 0.2333, 0.1240, 0.1082, 0.1581],
[0.1390, 0.2369, 0.2326, 0.1242, 0.1108, 0.1565],
[0.1435, 0.2074, 0.2046, 0.1462, 0.1263, 0.1720],
[0.1526, 0.1958, 0.1975, 0.1367, 0.1879, 0.1295],
[0.1385, 0.2184, 0.2128, 0.1420, 0.0988, 0.1896]])
```
在使用 PyTorch 时,像 `torch.softmax` 这样的函数中的 `dim` 参数指定了将在输入张量中的哪个维度上进行归一化计算。通过设置 `dim=-1`,我们指示 `softmax` 函数沿着 `attn_scores` 张量的最后一个维度进行归一化操作。如果 `attn_scores` 是一个二维张量(例如,形状为 `[行数, 列数]`),则 `dim=-1` 将沿列方向进行归一化,使得每一行的值(沿列方向求和)之和等于 1。
在继续执行第 3 步(即图 3.12 所示的最后一步)之前,我们先简单验证一下每一行的总和是否确实为 1:
```python
row_2_sum = sum([0.1385, 0.2379, 0.2333, 0.1240, 0.1082, 0.1581])
print("Row 2 sum:", row_2_sum)
print("All row sums:", attn_weights.sum(dim=-1))
```
结果如下:
```python
Row 2 sum: 1.0
All row sums: tensor([1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000])
```
在第 3 步也是最后一步中,我们使用这些注意力权重通过矩阵乘法的方式来并行计算所有的上下文向量:
```python
all_context_vecs = attn_weights @ inputs
print(all_context_vecs)
```
可以看到,计算输出的张量中,每一行包含一个三维的上下文向量:
```python
tensor([[0.4421, 0.5931, 0.5790],
[0.4419, 0.6515, 0.5683],
[0.4431, 0.6496, 0.5671],
[0.4304, 0.6298, 0.5510],
[0.4671, 0.5910, 0.5266],
[0.4177, 0.6503, 0.5645]])
```
我们可以通过将第二行与之前在第 3.3.1 节中计算的上下文向量 z(2) 进行对比,来再次确认代码的正确性。
```python
print("Previous 2nd context vector:", context_vec_2)
```
根据结果,我们可以看到之前计算的 context_vec_2 与以上张量的第二行完全一致:
```python
Previous 2nd context vector: tensor([0.4419, 0.6515, 0.5683])
```
以上内容完成了对简化自注意力机制的代码演示。在接下来的部分,我们将添加可训练的权重,使大语言模型能够从数据中学习并提升其在特定任务上的性能。
## 3.4 实现带有可训练权重的自注意力机制
在本节中,我们将实现一种在原始 Transformer 架构、GPT 模型以及大多数其他流行的大语言模型中使用的自注意力机制。这种自注意力机制也被称为缩放点积注意力。图 3.13 提供了一个概念框架,展示了这种自注意力机制如何应用在在大语言模型的架构设计中。
如图 3.13 所示,带有可训练权重的自注意力机制是基于之前简化自注意力机制的改进:我们希望计算某个特定输入元素的嵌入向量的加权和来作为上下文向量。您将看到,与我们在 3.3 节中编码的简化自注意力机制相比,只有细微的差别。
最显著的区别在于引入了在模型训练过程中不断更新的权重矩阵。这些可训练的权重矩阵至关重要,它们使模型(特别是模型内部的注意力模块)能够学习生成“优质”的上下文向量。(请注意,我们将在第 5 章训练大语言模型。)
我们将通过两个小节来深入讲解自注意力机制。首先,我们会像之前一样,逐步编写该机制的代码。然后,我们会将代码整理成一个紧凑的 Python 类,以便在之后第 4 章编写的大语言模型(LLM)架构中使用。
### 3.4.1 逐步计算注意力权重
我们通过引入三个可训练的权重矩阵:Wq、Wk 和 Wv 来逐步实现自注意力机制。这三个矩阵用于将嵌入后的输入 token x(i) 映射为查询向量、键向量和值向量(如图 3.14 所示)。
在 3.3.1 节中,我们将第二个输入元素 x(2) 定义为查询(query),通过计算简化的注意力权重来得到上下文向量 z(2)。随后,在第 3.3.2 节中,我们将这一过程推广到整个输入句子 "Your journey starts with one step",为这六个词的输入句子计算所有的上下文向量 z(1) 到 z(T)。
同样地,为了便于说明,我们将先计算一个上下文向量 z(2)。接下来,我们将修改代码以计算所有的上下文向量。让我们从定义一些变量开始:
```python
x_2 = inputs[1] #A
d_in = inputs.shape[1] #B
d_out = 2 #C
#A 第二个输入元素
#B 输入维度, d_in=3
#C 输出维度, d_out=2
```
请注意,在 GPT 类模型中,输入维度和输出维度通常是相同的。不过,为了便于说明和更清楚地展示计算过程,我们在此选择了不同的输入(d_in=3)和输出(d_out=2)维度。
接下来,我们初始化图3.14中所示的三个权重矩阵Wq、Wk和Wv:
```python
torch.manual_seed(123)
W_query = torch.nn.Parameter(torch.rand(d_in, d_out), requires_grad=False)
W_key = torch.nn.Parameter(torch.rand(d_in, d_out), requires_grad=False)
W_value = torch.nn.Parameter(torch.rand(d_in, d_out), requires_grad=False)
```
请注意,这里我们将 `requires_grad` 设置为 `False`,以便在输出结果中减少不必要的信息,从而使演示更加清晰。但如果要将这些权重矩阵用于模型训练,则需要将 `requires_grad` 设置为 `True`,以便在模型训练过程中更新这些矩阵。
接下来,我们计算之前在图 3.14 中展示的 query、key 和 value 向量:
```python
query_2 = x_2 @ W_query
key_2 = x_2 @ W_key
value_2 = x_2 @ W_value
print(query_2)
```
以上代码的输出是一个二维向量,因为我们将对应的输出权重矩阵的列数通过 `d_out` 参数设置为 2:
```python
tensor([0.4306, 1.4551])
```
> [!NOTE]
>
> **权重参数 VS 注意力权重**
>
> 请注意,在权重矩阵 W 中,术语“权重”是“权重参数”的缩写,指的是神经网络在训练过程中被优化的数值参数。这与注意力权重不同,注意力权重用于确定上下文向量对输入文本的不同部分的依赖程度,即神经网络对输入不同部分的关注程度。
>
> 总之,权重参数是神经网络的基本学习系数,用于定义网络层之间的连接关系,而注意力权重则是根据上下文动态生成的特定值,用于衡量不同词语或位置在当前上下文中的重要性。
尽管我们当前的目标仅仅是计算一个上下文向量 z(2),但仍然需要获取所有输入元素的 key 和 value 向量,因为它们将参与与查询向量 q(2) 一起计算注意力权重的过程,如图 3.14 所示。
我们可以通过矩阵乘法获取所有元素的key和value向量:
```python
keys = inputs @ W_key
values = inputs @ W_value
print("keys.shape:", keys.shape)
print("values.shape:", values.shape)
```
从输出结果可以看出,我们成功地将 6 个输入 token 从 3 维嵌入空间投影到 2 维嵌入空间:
```python
keys.shape: torch.Size([6, 2])
values.shape: torch.Size([6, 2])
```
接下来的第二步是计算注意力得分(如图 3.15 所示)。
首先,我们计算注意力得分ω22 :
```python
keys_2 = keys[1] #A
attn_score_22 = query_2.dot(keys_2)
print(attn_score_22)
#A 请牢记在Python中索引从0开始
```
由此得到以下未经归一化的注意力得分:
```python
tensor(1.8524)
```
> [!TIP]
>
> **个人思考:** 之前一直有一个疑惑,相同的两个词在不同句子中语义相关度可能完全不同,那么它们的注意力得分是如何做到在不同的上下文中分数不一样的。例如考虑以下两个句子:
>
> + 句子1:"The cat drank the milk because it was hungry."
> + 句子2:"The cat drank the milk because it was sweet."
>
> 很明显,在这两个句子中,`it`的指代不同,第一个句子中,`it`指代`cat`,而在第二个句子中,`it`指代`milk`。
>
> 根据以下注意力得分的公式(Qcat和Kit分别为`cat`和`it`的查询向量和键向量)可知,句子1中`score_cat_it`是要大于句子2中的`score_cat_it`,因为句子1中,`it`和`cat`的相关度更高,但是从公式中如何推断出实现呢?
>
> **score_cat_it = Qcat · Kit**
>
> 我们继续将公式拆解:
>
> **Qcat= Wq * (Ecat + Poscat)**
>
> **Kit = Wk * (Eit + Posit)**
>
> 其中 **Ecat**和**Eit**是这两个词的嵌入向量,表示该词的基本语义信息,在不同的上下文中是固定的,根据公式可知,要使最终算出的**score_cat_it**与上下文语义相关,最重要的是**Wq** 和**Wk**这两个权重参数应该能反映出不同上下文语义的相关性。在标准的自注意力机制中,W、K、V向量都是固定的,然而,由于 GPT 模型是由多层自注意力模块堆叠而成,每一层都会根据当前输入和上下文信息,动态调整查询、键和值向量的权重矩阵。因此,即使初始的词嵌入和权重矩阵是固定的,经过多层处理后,模型能够生成与当前上下文相关的 Q、K、V 向量权重矩阵,最终计算出的Q、K、V 向量也就能反映出上下文的语义了。GPT多层的实现的细节后文会详述。
我们可以再次通过矩阵乘法将其应用到所有注意力得分的计算:
```python
attn_scores_2 = query_2 @ keys.T # All attention scores for given query
print(attn_scores_2)
```
可以看到,输出中的第二个元素与我们之前计算的 `attn_score_22` 相同:
```python
tensor([1.2705, 1.8524, 1.8111, 1.0795, 0.5577, 1.5440])
```
第三步是将注意力得分转换为注意力权重,如图 3.16 所示。
接下来,如图 3.16 所示,我们通过缩放注意力得分并使用前面提到的 softmax 函数来计算注意力权重。与之前的不同之处在于,现在我们通过将注意力得分除以`keys`嵌入维度的平方根来进行缩放(注意,取平方根在数学上等同于指数为 0.5 的运算)。
```python
d_k = keys.shape[-1]
attn_weights_2 = torch.softmax(attn_scores_2 / d_k**0.5, dim=-1)
print(attn_weights_2)
```
结果如下:
```python
tensor([0.1500, 0.2264, 0.2199, 0.1311, 0.0906, 0.1820])
```
> [!NOTE]
>
> **缩放点积注意力机制的原理**
>
> 对嵌入维度大小进行归一化的原因是为了避免出现小梯度,从而提高训练性能。例如,当嵌入维度增大时(在 GPT 类大型语言模型中通常超过一千),较大的点积在反向传播中应用 softmax 函数后,可能会导致非常小的梯度。随着点积的增大,softmax 函数的行为会更加类似于阶跃函数,导致梯度接近于零。这些小梯度可能会显著减慢学习速度,甚至导致训练停滞。
>
> 通过嵌入维度的平方根进行缩放,正是自注意力机制被称为‘缩放点积注意力’的原因。
> [!TIP]
>
> **个人思考:** 这里再稍微解释一下上述关于缩放点积注意力的机制。在自注意力机制中,查询向量(Query)与键向量(Key)之间的点积用于计算注意力权重。然而,当嵌入维度(embedding dimension)较大时,点积的结果可能会非常大。那么大的点积对接下来的计算有哪些具体影响呢?
>
> + **Softmax函数的特性**:在计算注意力权重时,点积结果会通过Softmax函数转换为概率分布。而Softmax函数对输入值的差异非常敏感,当输入值较大时,Softmax的输出会趋近于0或1,表现得类似于阶跃函数(step function)。
> + **梯度消失问题**:当Softmax的输出接近0或1时,其梯度会非常小,接近于零(可以通过3.3.1小节中提到的Softmax公式推断)。这意味着在反向传播过程中,梯度更新幅度会很小,导致模型学习速度减慢,甚至训练停滞。
>
> 为了解决上述问题,在计算点积后,将结果除以嵌入维度的平方根(即 $` \sqrt{dk} `$),其中 dk 是键向量的维度。这样可以将点积结果缩放到适当的范围,避免Softmax函数进入梯度平缓区,从而保持梯度的有效性,促进模型的正常训练。
好了,我们只剩最后一步,也就是计算上下文向量,如图3.17所示。
与第 3.3 节中我们通过输入向量的加权和来计算上下文向量相似,现在我们通过值向量的加权和来计算上下文向量。这里,注意力权重作为加权因子,用于衡量每个值向量的重要性。与第 3.3 节类似,我们可以通过矩阵乘法一步得到输出结果:
```python
context_vec_2 = attn_weights_2 @ values
print(context_vec_2)
```
结果如下:
```python
tensor([0.3061, 0.8210])
```
到目前为止,我们只计算了一个上下文向量 z(2)。在下一节中,我们将完善代码,以计算输入序列中的所有上下文向量,从 z(1) 到 z(T)。
> [!NOTE]
>
> **为什么使用`Q`、`K`和`V`向量?**
>
> 在注意力机制的上下文中,“键”(key)、“查询”(query)和“值”(value)这些术语来源于信息检索和数据库领域,在这些领域中也使用类似的概念来存储、搜索和检索信息
>
> **查询**(query)类似于数据库中的搜索查询。它代表模型当前关注或试图理解的项(如句子中的某个词或 token)。通过查询,模型可以探查输入序列中的其他部分,以确定对它们应关注的程度。
>
> **键**(key)类似于数据库中用于索引和查找的键。在注意力机制中,输入序列的每个元素(例如句子中的每个单词)都对应一个关联的‘键’。这些‘键’用于与‘查询’进行匹配。
>
> **值**(value)类似于数据库中的键值对中的“值”。它表示输入项的实际内容或表示。当模型确定哪些键(即输入中的哪些部分)与查询(当前的关注项)最相关时,就会检索出对应的值。
### 3.4.2 实现一个简洁的自注意力机制 Python 类
在前面的章节中,我们逐步讲解了计算自注意力输出的多个步骤。这样做主要是为了便于分步骤展示每个环节的细节。在实际应用中,考虑到下一章将介绍的大语言模型的实现,采用如下方式将这段代码组织到一个 Python 类中会更为有利:
```python
# Listing 3.1 A compact self-attention class
import torch.nn as nn
class SelfAttention_v1(nn.Module):
def __init__(self, d_in, d_out):
super().__init__()
self.d_out = d_out
self.W_query = nn.Parameter(torch.rand(d_in, d_out))
self.W_key = nn.Parameter(torch.rand(d_in, d_out))
self.W_value = nn.Parameter(torch.rand(d_in, d_out))
def forward(self, x):
keys = x @ self.W_key
queries = x @ self.W_query
values = x @ self.W_value
attn_scores = queries @ keys.T # omega
attn_weights = torch.softmax(
attn_scores / keys.shape[-1]**0.5, dim=-1)
context_vec = attn_weights @ values
return context_vec
```
在这段 PyTorch 代码中,`SelfAttention_v1` 是一个从 `nn.Module` 派生的类。`nn.Module` 是 PyTorch 模型的基础组件,提供了创建和管理模型层所需的必要功能。
`__init__` 方法初始化了用于计算查询(query)、键(key)和值(value)的可训练权重矩阵(`W_query`、`W_key` 和 `W_value`),每个矩阵都将输入维度 `d_in` 转换为输出维度 `d_out`。
前向传播过程在 forward 方法中实现,我们通过将查询(query)和键(key)相乘来计算注意力得分(attn_scores),并使用 softmax 对这些得分进行归一化。最后,我们使用这些归一化的注意力得分对值(value)加权,生成上下文向量。
我们可以按如下方式使用这个类:
```python
torch.manual_seed(123)
sa_v1 = SelfAttention_v1(d_in, d_out)
print(sa_v1(inputs))
```
由于输入包含六个嵌入向量,因此会生成一个用于存储这六个上下文向量的矩阵:
```python
tensor([[0.2996, 0.8053],
[0.3061, 0.8210],
[0.3058, 0.8203],
[0.2948, 0.7939],
[0.2927, 0.7891],
[0.2990, 0.8040]], grad_fn=)
```
观察以上的输出,注意第二行 ([0.3061, 0.8210]) 的内容与上一节中的 `context_vec_2` 内容一致。
图 3.18 概述了我们刚刚实现的自注意力机制。
如图3.18所示,自注意力机制涉及可训练的权重矩阵 Wq、Wk 和 Wv。这些矩阵将输入数据转换为查询、键和值,它们是自注意力机制的重要组成部分。随着训练过程中数据量的增加,模型会不断调整这些可训练的权重,在后续章节中我们会学习相关细节。
我们可以通过使用 PyTorch 的 `nn.Linear` 层来进一步改进 SelfAttention_v1 的实现。当禁用偏置单元时,`nn.Linear` 层可以有效地执行矩阵乘法。此外,使用 `nn.Linear` 替代手动实现的 `nn.Parameter(torch.rand(...))` 的一个显著优势在于,`nn.Linear` 具有优化的权重初始化方案,从而有助于实现更稳定和更高效的模型训练。
```python
# Listing 3.2 A self-attention class using PyTorch's Linear layers
class SelfAttention_v2(nn.Module):
def __init__(self, d_in, d_out, qkv_bias=False):
super().__init__()
self.d_out = d_out
self.W_query = nn.Linear(d_in, d_out, bias=qkv_bias)
self.W_key = nn.Linear(d_in, d_out, bias=qkv_bias)
self.W_value = nn.Linear(d_in, d_out, bias=qkv_bias)
def forward(self, x):
keys = self.W_key(x)
queries = self.W_query(x)
values = self.W_value(x)
attn_scores = queries @ keys.T
attn_weights = torch.softmax(attn_scores / keys.shape[-1]**0.5, dim=-1)
context_vec = attn_weights @ values
return context_vec
```
SelfAttention_v2 的使用方法和 SelfAttention_v1 一样:
```python
torch.manual_seed(789)
sa_v2 = SelfAttention_v2(d_in, d_out)
print(sa_v2(inputs))
```
输出如下:
```python
tensor([[-0.0739, 0.0713],
[-0.0748, 0.0703],
[-0.0749, 0.0702],
[-0.0760, 0.0685],
[-0.0763, 0.0679],
[-0.0754, 0.0693]], grad_fn=)
```
`SelfAttention_v1` 和` SelfAttention_v2` 的输出不同,因为它们的权重矩阵使用了不同的初始权重,根本原因在于 `nn.Linear` 层采用了一种更复杂的权重初始化方案。
> [!NOTE]
>
> **练习 3.1:比较`SelfAttention_v1`和 `SelfAttention_v2`**
>
> 请注意,`SelfAttention_v2` 中的 `nn.Linear` 层使用了一种不同的权重初始化方式,而 `SelfAttention_v1` 则使用 `nn.Parameter(torch.rand(d_in, d_out))` 进行初始化。这导致两种机制生成的结果有所不同。为了验证 `SelfAttention_v1` 和 `SelfAttention_v2` 的其他部分是否相似,我们可以将 `SelfAttention_v2` 对象中的权重矩阵转移到 `SelfAttention_v1` 中,从而使两者生成相同的结果。
>
> 你的任务是将 `SelfAttention_v2` 实例中的权重正确分配给 `SelfAttention_v1` 实例。为此,你需要理解两个版本中权重之间的关系。(提示:`nn.Linear` 存储的是转置形式的权重矩阵。)分配完成后,你应该能观察到两个实例生成相同的输出。
在下一节中,我们将对自注意力机制进行增强,重点加入因果和多头机制。因果属性涉及对注意力机制的修改,防止模型访问序列中的后续信息。这在语言建模等任务中至关重要,因为在这些任务中,每个词的预测只能依赖之前的词。
多头组件将注意力机制分解为多个‘头’。每个头能够学习数据的不同方面,使模型能够同时关注来自不同表示子空间的不同位置的信息。这提高了模型在复杂任务中的性能。
## 3.5 使用因果注意力机制来屏蔽后续词
在本节中,我们将标准自注意力机制修改为因果注意力机制,这对于后续章节中开发大语言模型至关重要。
因果注意力(也称为掩蔽注意力)是一种特殊的自注意力形式。它限制模型在处理任何给定的 token 时,只能考虑序列中的前一个和当前输入,而不能看到后续的内容。这与标准的自注意力机制形成对比,后者允许模型同时访问整个输入序列。
因此,在计算注意力分数时,因果注意力机制确保模型只考虑当前 token 或之前的 token。
在 GPT 类大语言模型中,要实现这一点,我们需要对每个处理的 token 屏蔽其后续 token,即在输入文本中当前词之后的所有词,如图 3.19 所示。
如图 3.19 所示,我们对注意力权重的对角线上方部分进行了掩码操作,并对未掩码的注意力权重进行归一化,使得每一行的注意力权重之和为 1。在下一节中,我们将用代码实现这个掩码和归一化过程。
### 3.5.1 应用因果注意力掩码
在本节中,我们将编码实现因果注意力掩码。我们首先按照图 3.20 中总结的步骤开始。
如图3.20总结,我们可以利用上一节的注意力得分和权重来实现因果注意力机制,以获得掩码后的注意力权重。
在图 3.20 所示的第一步中,我们使用 softmax 函数计算注意力权重,如在前几节中所做的那样:
```python
queries = sa_v2.W_query(inputs) #A
keys = sa_v2.W_key(inputs)
attn_scores = queries @ keys.T
attn_weights = torch.softmax(attn_scores / keys.shape[-1]**0.5, dim=1)
print(attn_weights)
#A 为了方便起见,我们复用上一节中 SelfAttention_v2 对象的query和key权重矩阵。
```
这会得到以下注意力权重:
```python
tensor([[0.1921, 0.1646, 0.1652, 0.1550, 0.1721, 0.1510],
[0.2041, 0.1659, 0.1662, 0.1496, 0.1665, 0.1477],
[0.2036, 0.1659, 0.1662, 0.1498, 0.1664, 0.1480],
[0.1869, 0.1667, 0.1668, 0.1571, 0.1661, 0.1564],
[0.1830, 0.1669, 0.1670, 0.1588, 0.1658, 0.1585],
[0.1935, 0.1663, 0.1666, 0.1542, 0.1666, 0.1529]],
grad_fn=)
```
我们可以使用 PyTorch 的 `tril` 函数来实现图 3.20 中的步骤 2,该函数生成一个掩码矩阵,使对角线以上的值为零:
```python
context_length = attn_scores.shape[0]
mask_simple = torch.tril(torch.ones(context_length, context_length))
print(mask_simple)
```
生成的掩码如下所示:
```python
tensor([[1., 0., 0., 0., 0., 0.],
[1., 1., 0., 0., 0., 0.],
[1., 1., 1., 0., 0., 0.],
[1., 1., 1., 1., 0., 0.],
[1., 1., 1., 1., 1., 0.],
[1., 1., 1., 1., 1., 1.]])
```
现在,我们可以将这个掩码矩阵与注意力权重相乘,从而将对角线以上的值置零。
```python
masked_simple = attn_weights*mask_simple
print(masked_simple)
```
可以看到,对角线以上的元素已成功被置零:
```python
tensor([[0.1921, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000],
[0.2041, 0.1659, 0.0000, 0.0000, 0.0000, 0.0000],
[0.2036, 0.1659, 0.1662, 0.0000, 0.0000, 0.0000],
[0.1869, 0.1667, 0.1668, 0.1571, 0.0000, 0.0000],
[0.1830, 0.1669, 0.1670, 0.1588, 0.1658, 0.0000],
[0.1935, 0.1663, 0.1666, 0.1542, 0.1666, 0.1529]],
grad_fn=)
```
图 3.20 中的第三步是将注意力权重重新归一化,使得每一行的权重和再次等于 1。我们可以通过将每一行中的每个元素除以该行的总和来实现这一点:
```python
row_sums = masked_simple.sum(dim=1, keepdim=True)
masked_simple_norm = masked_simple / row_sums
print(masked_simple_norm)
```
最终得到的注意力权重矩阵具有以下特性:主对角线以上的注意力权重被置零,每一行的权重和为 1:
```python
tensor([[1.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000],
[0.5517, 0.4483, 0.0000, 0.0000, 0.0000, 0.0000],
[0.3800, 0.3097, 0.3103, 0.0000, 0.0000, 0.0000],
[0.2758, 0.2460, 0.2462, 0.2319, 0.0000, 0.0000],
[0.2175, 0.1983, 0.1984, 0.1888, 0.1971, 0.0000],
[0.1935, 0.1663, 0.1666, 0.1542, 0.1666, 0.1529]],
grad_fn=)
```
> [!NOTE]
>
> **信息泄露**
>
> 当我们应用掩码并重新归一化注意力权重时,乍一看似乎未来的 token(即我们打算掩盖的部分)仍可能影响当前 token,因为它们的值仍然参与了 softmax 计算。然而,关键在于,当我们在掩码之后重新归一化注意力权重时,本质上是在一个更小的子集上重新计算 softmax(因为被掩盖的位置不会贡献到 softmax 的计算值中)。
>
> softmax 算法的优雅之处在于,尽管最初所有位置都包含在分母中,但经过掩码处理和重新归一化后,被掩盖的位置的影响被抵消了——它们在任何实质性意义上都不会影响 softmax 得分。
>
> 简而言之,在应用掩码和重新归一化之后,注意力权重的分布就像一开始只在未被掩码的位置上计算的一样。这确保了不会有来自未来(或其他掩码位置)的信息泄露,从而实现了我们的预期。
尽管通过上文的方式我们已经完成了因果注意力的实现,但我们还可以利用 softmax 函数的数学特性,更高效地计算掩码后的注意力权重,减少计算步骤,具体实现如图 3.21 所示。
Softmax 函数将输入值转换为概率分布。当一行中存在负无穷值(-∞)时,Softmax 函数会将这些值视为零概率。(从数学上讲,这是因为 e−∞ 接近于 0。)
我们可以通过创建一个对角线以上全为 1 的掩码,然后将这些 1 替换为负无穷大(-inf)值,从而实现这种更高效的掩码技巧:
```python
mask = torch.triu(torch.ones(context_length, context_length), diagonal=1)
masked = attn_scores.masked_fill(mask.bool(), -torch.inf)
print(masked)
```
由此生成以下掩码:
```python
tensor([[0.2899, -inf, -inf, -inf, -inf, -inf],
[0.4656, 0.1723, -inf, -inf, -inf, -inf],
[0.4594, 0.1703, 0.1731, -inf, -inf, -inf],
[0.2642, 0.1024, 0.1036, 0.0186, -inf, -inf],
[0.2183, 0.0874, 0.0882, 0.0177, 0.0786, -inf],
[0.3408, 0.1270, 0.1290, 0.0198, 0.1290, 0.0078]],
grad_fn=)
```
现在我们只需要对这些掩码后的结果应用 softmax 函数,就可以完成了:
```python
attn_weights = torch.softmax(masked / keys.shape[-1]**0.5, dim=1)
print(attn_weights)
```
如输出所示,每一行的值之和为 1,因此不再需要进一步的归一化:
```python
tensor([[1.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000],
[0.5517, 0.4483, 0.0000, 0.0000, 0.0000, 0.0000],
[0.3800, 0.3097, 0.3103, 0.0000, 0.0000, 0.0000],
[0.2758, 0.2460, 0.2462, 0.2319, 0.0000, 0.0000],
[0.2175, 0.1983, 0.1984, 0.1888, 0.1971, 0.0000],
[0.1935, 0.1663, 0.1666, 0.1542, 0.1666, 0.1529]],
grad_fn=)
```
现在,我们可以使用修改后的注意力权重,通过 `context_vec = attn_weights @ values` 来计算上下文向量,这在第 3.4 节中介绍过。不过,在下一节中,我们将首先介绍一个对因果注意力机制的细微调整,这一调整在训练大语言模型时有助于减少过拟合现象。
### 3.5.2 使用 dropout 遮掩额外的注意力权重
Dropout 在深度学习中是一种技术,即在训练过程中随机忽略一些隐藏层单元,实际上将它们“丢弃”。这种方法有助于防止过拟合,确保模型不会过于依赖任何特定的隐藏层单元组合。需要特别强调的是,Dropout 仅在训练过程中使用,训练结束后则会禁用。
在 Transformer 架构中(包括 GPT 等模型),注意力机制中的 Dropout 通常应用于两个特定区域:计算注意力得分之后,或将注意力权重应用于 value 向量之后。
在这里,我们会在计算完注意力权重之后应用 dropout 掩码(如图 3.22 所示),因为在实际应用中这是更为常见的做法。
在以下代码示例中,我们使用了50%的 dropout 率,这意味着屏蔽掉一半的注意力权重。(在后续章节中训练 GPT 模型时,我们将使用更低的 dropout 率,比如 0.1 或 0.2)
在以下代码中,我们首先将 PyTorch 的 dropout 实现应用于一个由 1 组成的 6×6 张量以作说明:
```python
torch.manual_seed(123)
dropout = torch.nn.Dropout(0.5) #A
example = torch.ones(6, 6) #B
print(dropout(example))
#A 我们使用的dropout率为0.5
#B 创建一个由1组成的矩阵
```
如我们所见,约一半的数值被置零:
```python
tensor([[2., 2., 0., 2., 2., 0.],
[0., 0., 0., 2., 0., 2.],
[2., 2., 2., 2., 0., 2.],
[0., 2., 2., 0., 0., 2.],
[0., 2., 0., 2., 0., 2.],
[0., 2., 2., 2., 2., 0.]])
```
当对注意力权重矩阵应用 50% 的 dropout 时,矩阵中一半的元素会被随机设置为零。为了补偿有效元素的减少,矩阵中剩余元素的值会被放大 1/0.5 = 2 倍。这个缩放操作至关重要,可以在训练和推理阶段保持注意力机制的整体权重平衡,确保注意力机制在这两个阶段的平均影响保持一致。
> [!TIP]
>
> **个人思考:** 读到这一段时,我有些不解,Dropout相当于丢弃一定比例的注意力权重,这表明对输入中的某些token关注度降为0了(完全不关注),这样的处理方式难道对最终的预测效果没有影响么?另外如何理解Dropout之后的缩放操作是为了保持注意力在不同阶段的平衡?
>
> 经过查阅额外的资料及深度思考,我觉得可以从以下几个方面理解上述的疑问:
>
> 1. **Dropout 的目的:提高模型的泛化能力**
>
> dropout 的设计初衷是**提高模型的泛化能力**。通过随机丢弃一部分神经元或注意力权重,dropout 迫使模型在每次训练时学习略有不同的表示方式,而不是依赖某一特定的注意力模式。这种随机化的训练方式可以帮助模型在**面对新数据时更具鲁棒性**,减少过拟合的风险。
>
> 2. **注意力机制的冗余性**
>
> 在 Transformer 的注意力机制中,模型通常会对多个 token 进行注意力计算,实际上会有一些冗余信息。也就是说,**不同 token 之间的信息通常会有部分重叠**,并且模型能够从多个来源获取类似的信息。在这种情况下,dropout 随机丢弃一部分注意力权重并不会完全破坏模型的性能,因为模型可以依赖于其他未被丢弃的注意力路径来获取所需信息。
>
> 3. **缩放操作的作用**
>
> 在应用 dropout 时,一部分注意力权重被随机置零(假设 dropout 率为 p)。剩余的权重会被放大,其放大倍数为 $` \frac{1}{1-p} `$。放大后的权重记为 z′:
>
> $$z_{i}^{\prime}=\frac{z_{i}}{1-p} \quad \text { (对于未被置零的权重) }$$
>
> 此时,未被置零的注意力权重 z′\mathbf{z}'z′ 将作为 Softmax 的输入。因此,dropout 后的缩放对 Softmax 有两个主要影响:
>
> + **增大未遮盖值的相对差异**:放大剩余权重后,它们的数值相对于被置零的权重增大,从而拉大了非零元素之间的相对差异。这使得在 Softmax 计算中(通过前文提过的Softmax公式推导,输入值的**差异越大**,输出分布就会**越尖锐**;而输入值差异越小,输出分布就会越**平滑**),剩下的值之间的对比更明显。
> + **影响 Softmax 输出的分布形态**:当未被置零的权重值被放大后,它们在 Softmax 输出中会更具代表性,注意力分布会更集中(即更尖锐),让模型更关注特定的 token。
>
> 缩放后的 Softmax 输入导致注意力分布更倾向于少数的高权重 token,使得模型在当前步骤更关注这些 token 的信息。这对模型的影响包括:
>
> + **增强模型的选择性关注**:在训练中,模型会在每个步骤中随机选择不同的 token 进行更高的关注,这使模型在学习时不会依赖特定 token 的注意力。
> + **确保总注意力强度保持一致**:即便经过 dropout 丢弃了一部分权重,缩放保证了剩余权重在 Softmax 后的分布与未应用 dropout 时类似。
>
> 4. **训练过程中多次迭代弥补信息丢失**
>
> 在训练过程中,每个 batch 中的 dropout 掩码都是随机生成的。也就是说,在每次训练时被丢弃的注意力权重是随机的,并不会始终忽略相同的 token。这种**随机性确保了在训练过程中,模型会在多个迭代中多次关注到每个 token**。因此,即便某个 token 在当前的训练步中被忽略,在未来的训练步骤中它仍然会被关注到,从而在整体上避免了信息丢失的问题。
现在,让我们将 dropout 应用于注意力权重矩阵本身:
```python
torch.manual_seed(123)
print(dropout(attn_weights))
```
由此生成的注意力权重矩阵中,部分元素被置零,剩余的元素重新进行了缩放:
```python
tensor([[2.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000],
[0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000],
[0.7599, 0.6194, 0.6206, 0.0000, 0.0000, 0.0000],
[0.0000, 0.4921, 0.4925, 0.0000, 0.0000, 0.0000],
[0.0000, 0.3966, 0.0000, 0.3775, 0.0000, 0.0000],
[0.0000, 0.3327, 0.3331, 0.3084, 0.3331, 0.0000]],
grad_fn=
```
请注意,由于操作系统的不同,生成的 dropout 输出可能看起来有所差异;您可以在 PyTorch 问题跟踪页面上查看更多关于此不一致性的信息,网址为:[https://github.com/pytorch/pytorch/issues/121595](https://github.com/pytorch/pytorch/issues/121595)。
在理解了因果注意力和 dropout 掩码的基础上,接下来的部分中我们将开发一个简洁的 Python 类,以便高效应用这两种技术。
### 3.5.3 实现一个简洁的因果注意力类
在本节中,我们将把因果注意力和 dropout 的修改整合到在 3.4 节开发的 `SelfAttention` Python 类中。该类将作为模板,用于接下来一节中开发多头注意力(多头注意力将是我们在本章实现的最后一个注意力类)。
但在开始之前,还需确保代码能够处理由多个输入组成的批次,以便 `CausalAttention` 类能够支持我们在第 2 章中实现的数据加载器所生成的批次输出。
为了简单起见,我们复制输入文本示例以模拟批量输入:
```python
batch = torch.stack((inputs, inputs), dim=0)
print(batch.shape) #A
#A 2个输入,每个输入有6个token,每个token的嵌入维度为3
```
以上代码生成一个三维张量,包含 2 个输入文本,每个文本包含 6 个 token,每个 token 表示为一个 3 维嵌入向量:
```python
torch.Size([2, 6, 3])
```
以下的 CausalAttention 类与我们之前实现的 SelfAttention 类类似,不同之处在于我们现在添加了dropout和因果掩码组件,如以下代码所示:
```python
# Listing 3.3 A compact causal attention class
class CausalAttention(nn.Module):
def __init__(self, d_in, d_out, context_length, dropout, qkv_bias=False):
super().__init__()
self.d_out = d_out
self.W_query = nn.Linear(d_in, d_out, bias=qkv_bias)
self.W_key = nn.Linear(d_in, d_out, bias=qkv_bias)
self.W_value = nn.Linear(d_in, d_out, bias=qkv_bias)
self.dropout = nn.Dropout(dropout) #A
self.register_buffer(
'mask',
torch.triu(torch.ones(context_length, context_length),
diagonal=1)
) #B
def forward(self, x):
b, num_tokens, d_in = x.shape #C
keys = self.W_key(x)
queries = self.W_query(x)
values = self.W_value(x)
attn_scores = queries @ keys.transpose(1, 2) #C
attn_scores.masked_fill_( #D
self.mask.bool()[:num_tokens, :num_tokens], -torch.inf)
attn_weights = torch.softmax(attn_scores / keys.shape[-1]**0.5, dim=-1)
attn_weights = self.dropout(attn_weights)
context_vec = attn_weights @ values
return context_vec
#A 与之前的 SelfAttention_v1 类相比,我们添加了一个 dropout 层
#B register_buffer 调用也是新添加的内容(后续内容会提供更多相关信息)
#C 我们交换第 1 和第 2 个维度,同时保持批次维度在第1个位置(索引0)
#D 在 PyTorch 中,带有下划线后缀的操作会在原有内存空间执行,直接修改变量本身,从而避免不必要的内存拷贝
```
虽然新增的代码行与之前章节介绍的内容基本一致,但我们现在在 `__init__` 方法中添加了 `self.register_buffer()` 的调用。`register_buffer` 在 PyTorch 中并非所有情况下都必须使用,但在这里有其独特的优势。例如,当我们在大语言模型(LLM)中使用 `CausalAttention` 类时,buffer 会自动随模型迁移到合适的设备(CPU 或 GPU)。这意味着我们无需手动确保这些张量与模型参数在同一设备上,从而避免设备不匹配错误。
我们可以按如下方式使用 `CausalAttention` 类(类似于之前的 `SelfAttention`):
```python
torch.manual_seed(123)
context_length = batch.shape[1]
ca = CausalAttention(d_in, d_out, context_length, 0.0)
context_vecs = ca(batch)
print("context_vecs.shape:", context_vecs.shape)
```
生成的上下文向量是一个三维张量,其中每个 token 现在都表示为一个二维嵌入:
```python
context_vecs.shape: torch.Size([2, 6, 2])
```
图 3.23 提供了一个概念框架,总结了我们迄今为止完成的内容。
如图 3.23 所示,本节我们重点介绍了神经网络中的因果注意力的概念和实现。在下一节中,我们将进一步扩展这一概念,实现一个多头注意力模块,该模块可以并行实现多个因果注意力机制。
## 3.6 从单头注意力扩展到多头注意力
在本章的最后一部分中,我们将之前实现的因果注意力类扩展为多头形式,这也称为多头注意力。
多头’一词指的是将注意力机制划分为多个‘头’,每个头独立运作。在这种情况下,单个因果注意力模块可以视为单头注意力,即只有一组注意力权重用于按顺序处理输入。
在接下来的小节中,我们将讨论从因果注意力扩展到多头注意力的过程。第一小节将通过堆叠多个因果注意力模块,直观地构建一个多头注意力模块以作说明。第 2 小节将以一种更复杂但计算效率更高的方式实现相同的多头注意力模块。
### 3.6.1 堆叠多层单头注意力层
在实际应用中,实现多头注意力需要创建多个自注意力机制的实例(在 3.4.1 节的图 3.18 中已有展示),每个实例都具有独立的权重,然后将它们的输出合并。多个自注意力机制实例的应用属于计算密集型(CPU密集型)操作,但它对于识别复杂模式至关重要,这是基于 Transformer 的大语言模型所擅长的能力之一。
图3.24展示了多头注意力模块的结构,该模块由多个单头注意力模块组成,如图3.18所示,彼此堆叠在一起。
如前所述,多头注意力机制的核心思想是在并行运行多个注意力机制的过程中,对输入数据(如注意力机制中的 query、key 和 value 向量)使用不同的、可学习的线性投影。具体来说,就是将这些输入数据与权重矩阵相乘,得到不同的投影结果。
在代码中,我们可以通过实现一个简单的 `MultiHeadAttentionWrapper` 类来实现这一点,该类会堆叠多个我们之前实现的 `CausalAttention` 模块的实例:
```python
# Listing 3.4 A wrapper class to implement multi-head attention
class MultiHeadAttentionWrapper(nn.Module):
def __init__(self, d_in, d_out, context_length,
dropout, num_heads, qkv_bias=False):
super().__init__()
self.heads = nn.ModuleList(
[CausalAttention(d_in, d_out, context_length, dropout, qkv_bias)
for _ in range(num_heads)]
)
def forward(self, x):
return torch.cat([head(x) for head in self.heads], dim=-1)
```
例如,如果我们使用这个 MultiHeadAttentionWrapper 类,并通过设置 num_heads=2 使用两个注意力头,同时将 CausalAttention 的输出维度 d_out 设置为 2,那么生成的上下文向量将是 4 维的(d_out*num_heads=4),如图 3.25 所示。
为了通过一个具体的例子进一步说明图 3.25,我们可以按如下方式使用 MultiHeadAttentionWrapper 类(使用方式类似于之前的 CausalAttention 类):
```python
torch.manual_seed(123)
context_length = batch.shape[1] # This is the number of tokens
d_in, d_out = 3, 2
mha = MultiHeadAttentionWrapper(d_in, d_out, context_length, 0.0, num_heads=2)
context_vecs = mha(batch)
print(context_vecs)
print("context_vecs.shape:", context_vecs.shape)
```
以上代码输出的上下文向量如下所示:
```python
tensor([[[-0.4519, 0.2216, 0.4772, 0.1063],
[-0.5874, 0.0058, 0.5891, 0.3257],
[-0.6300, -0.0632, 0.6202, 0.3860],
[-0.5675, -0.0843, 0.5478, 0.3589],
[-0.5526, -0.0981, 0.5321, 0.3428],
[-0.5299, -0.1081, 0.5077, 0.3493]],
[[-0.4519, 0.2216, 0.4772, 0.1063],
[-0.5874, 0.0058, 0.5891, 0.3257],
[-0.6300, -0.0632, 0.6202, 0.3860],
[-0.5675, -0.0843, 0.5478, 0.3589],
[-0.5526, -0.0981, 0.5321, 0.3428],
[-0.5299, -0.1081, 0.5077, 0.3493]]], grad_fn=)
context_vecs.shape: torch.Size([2, 6, 4])
```
由此生成的 context_vecs 张量的第一个维度是 2,因为我们有两个输入文本(输入文本被复制,因此它们的上下文向量完全相同)。第二个维度对应每个输入中的 6 个 token。第三个维度对应每个 token 的 4 维嵌入向量。
> [!NOTE]
>
> **练习 3.2:返回二维嵌入向量**
>
> 更改 `MultiHeadAttentionWrapper(..., num_heads=2)` 调用中的输入参数,使输出的上下文向量为 2 维而不是 4 维,同时保持 `num_heads=2` 的设置。提示:无需修改类的实现,只需更改其中一个输入参数即可。
在本节中,我们实现了一个 `MultiHeadAttentionWrapper`,用于组合多个单头注意力模块。不过需要注意的是,在 `forward` 方法中,这些模块是通过 `[head(x) for head in self.heads]` 串行处理的。我们可以通过并行处理各个注意力头来优化该实现。实现这一目标的一种方法是,通过矩阵乘法同时计算所有注意力头的输出,我们将在下一节中详细探讨。
### 3.6.2 通过权重分割实现多头注意力机制
在前一节中,我们创建了一个 MultiHeadAttentionWrapper,通过堆叠多个单头注意力模块来实现多头注意力。这是通过实例化并组合多个 CausalAttention 对象实现的。
与其维护两个独立的类 MultiHeadAttentionWrapper 和 CausalAttention,我们可以将这两个概念合并为一个 MultiHeadAttention 类。此外,除了简单地合并 MultiHeadAttentionWrapper 和 CausalAttention 的代码外,我们还会进行一些额外的修改,以更高效地实现多头注意力机制。
在 `MultiHeadAttentionWrapper` 中,多头机制是通过创建一个包含多个 `CausalAttention` 对象的列表(`self.heads`)来实现的,每个对象代表一个独立的注意力头。`CausalAttention` 类独立执行注意力机制,每个头的结果最终被拼接起来。相比之下,接下来的 `MultiHeadAttention` 类则将多头功能集成在一个单一的类中。它通过对变换后的query、key和value张量进行重塑,将输入分割成多个头,并在计算注意力后将这些头的结果组合在一起。
在进一步讨论之前,让我们先看一下 MultiHeadAttention 类的实现:
```python
# Listing 3.5 An efficient multi-head attention class
class MultiHeadAttention(nn.Module):
def __init__(self, d_in, d_out,
context_length, dropout, num_heads, qkv_bias=False):
super().__init__()
assert d_out % num_heads == 0, "d_out must be divisible by num_heads"
self.d_out = d_out
self.num_heads = num_heads
self.head_dim = d_out // num_heads #A
self.W_query = nn.Linear(d_in, d_out, bias=qkv_bias)
self.W_key = nn.Linear(d_in, d_out, bias=qkv_bias)
self.W_value = nn.Linear(d_in, d_out, bias=qkv_bias)
self.out_proj = nn.Linear(d_out, d_out) #B
self.dropout = nn.Dropout(dropout)
self.register_buffer(
'mask',
torch.triu(torch.ones(context_length, context_length), diagonal=1)
)
def forward(self, x):
b, num_tokens, d_in = x.shape
keys = self.W_key(x) #C
queries = self.W_query(x) #C
values = self.W_value(x) #C
keys = keys.view(b, num_tokens, self.num_heads, self.head_dim) #D
values = values.view(b, num_tokens, self.num_heads, self.head_dim) #D
queries = queries.view(b, num_tokens, self.num_heads, self.head_dim) #D
keys = keys.transpose(1, 2) #E
queries = queries.transpose(1, 2) #E
values = values.transpose(1, 2) #E
attn_scores = queries @ keys.transpose(2, 3) #F
mask_bool = self.mask.bool()[:num_tokens, :num_tokens] #G
attn_scores.masked_fill_(mask_bool, -torch.inf) #H
attn_weights = torch.softmax(
attn_scores / keys.shape[-1]**0.5, dim=-1)
attn_weights = self.dropout(attn_weights)
context_vec = (attn_weights @ values).transpose(1, 2) #I
context_vec = context_vec.contiguous().view(b, num_tokens, self.d_out) #J
context_vec = self.out_proj(context_vec) #K
return context_vec
#A 将投影维度缩小,以匹配期望的输出维度
#B 使用线性层组合头部输出
#C 张量形状:(b, num_tokens, d_out)
#D 我们通过添加 num_heads 维度来隐式地拆分矩阵。然后展开最后一个维度,使其形状从 (b, num_tokens, d_out) 转换为 (b, num_tokens, num_heads, head_dim)
#E 将张量的形状从 (b, num_tokens, num_heads, head_dim) 转置为 (b, num_heads, num_tokens, head_dim)
#F 对每个注意力头进行点积运算
#G 掩码被截断到 token 的数量
#H 使用掩码填充注意力分数
#I 张量形状:(b, num_tokens, n_heads, head_dim)
#J 将多个注意力头的输出结果合并,其中输出维度 self.d_out 等于注意力头数 self.num_heads 与每个头的维度 self.head_dim 的乘积
#K 添加一个可选的线性投影层
```
尽管 `MultiHeadAttention` 类中张量的重塑(.view)和转置(.transpose)操作看起来非常复杂,但从数学角度来看,`MultiHeadAttention` 类与之前的 `MultiHeadAttentionWrapper` 类实现的概念是相同的。
从宏观层面上看,在之前的 MultiHeadAttentionWrapper 中,我们通过堆叠多个单头注意力层的方式来组合成一个多头注意力层。而 MultiHeadAttention 类采用了一种集成的方法:它从一个多头注意力层开始,并在内部将该层分解为各个独立的注意力头,如图 3.26 所示。
如图 3.26 所示,query、key 和 value 张量的拆分是通过张量的重塑和转置操作实现的,这些操作分别使用了 PyTorch 的 `.view` 和 `.transpose` 方法。首先,通过线性层对输入进行投影(分别生成 query、key 和 value),然后将其重塑为多个注意力头的形式。
关键操作是将 `d_out` 维度拆分成 `num_heads` 和 `head_dim`,其中 `head_dim = d_out / num_heads`。这种拆分通过 `.view` 方法实现:将形状为 `(b, num_tokens, d_out)` 的张量重塑为 `(b, num_tokens, num_heads, head_dim)`。
接下来对张量进行转置操作,将 `num_heads` 维度移动到 `num_tokens` 维度之前,使其形状变为 `(b, num_heads, num_tokens, head_dim)`。这种转置对于在不同注意力头之间正确对齐查询(queries)、键(keys)和值(values),并高效执行批量矩阵乘法至关重要。
为了说明这种批量矩阵乘法,假设我们有如下示例张量:
```python
a = torch.tensor([[[[0.2745, 0.6584, 0.2775, 0.8573], #A
[0.8993, 0.0390, 0.9268, 0.7388],
[0.7179, 0.7058, 0.9156, 0.4340]],
[[0.0772, 0.3565, 0.1479, 0.5331],
[0.4066, 0.2318, 0.4545, 0.9737],
[0.4606, 0.5159, 0.4220, 0.5786]]]])
#A 该张量的形状为 (b, num_heads, num_tokens, head_dim) = (1, 2, 3, 4)
```
接下来,我们在张量本身与张量的一个视图之间执行批量矩阵乘法操作,其中张量的视图将最后两个维度(num_tokens 和 head_dim)进行了转置:
```python
print(a @ a.transpose(2, 3))
```
结果如下:
```python
tensor([[[[1.3208, 1.1631, 1.2879],
[1.1631, 2.2150, 1.8424],
[1.2879, 1.8424, 2.0402]],
[[0.4391, 0.7003, 0.5903],
[0.7003, 1.3737, 1.0620],
[0.5903, 1.0620, 0.9912]]]])
```
在这种情况下,PyTorch 中的矩阵乘法实现能够处理四维输入张量,因此矩阵乘法会在输入张量的最后两个维度(即 `num_tokens` 和 `head_dim`)之间执行,并对每个注意力头重复该操作。
上述方法成为了一种更简洁的方式,可以单独计算每个头的矩阵乘法:
```python
first_head = a[0, 0, :, :]
first_res = first_head @ first_head.T
print("First head:\n", first_res)
second_head = a[0, 1, :, :]
second_res = second_head @ second_head.T
print("\nSecond head:\n", second_res)
```
该结果与我们之前使用批量矩阵乘法 `print(a @ a.transpose(2, 3))` 时获得的结果完全相同:
```python
First head:
tensor([[1.3208, 1.1631, 1.2879],
[1.1631, 2.2150, 1.8424],
[1.2879, 1.8424, 2.0402]])
Second head:
tensor([[0.4391, 0.7003, 0.5903],
[0.7003, 1.3737, 1.0620],
[0.5903, 1.0620, 0.9912]])
```
在多头注意力机制中,计算完注意力权重和上下文向量之后,将所有头的上下文向量转置回形状 `(b, num_tokens, num_heads, head_dim)`。然后将这些向量重新塑形(展平)为 `(b, num_tokens, d_out)` 的形状,从而有效地将所有头的输出组合在一起。
此外,我们在多头注意力机制中添加了一个称为输出投影层(self.out_proj)的模块,用于在组合多个头的输出后进行投影。而在因果注意力类中并没有这个投影层。这个输出投影层并非绝对必要(详见附录 B 的参考部分),但由于它在许多 LLM 架构中被广泛使用,因此我们在这里加上以保持完整性。
尽管 `MultiHeadAttention` 类由于额外的张量重塑和转置操作看起来比 `MultiHeadAttentionWrapper` 更复杂,但它更加高效。原因在于,我们只需执行一次矩阵乘法即可计算键(keys),对于查询(queries)和值(values)也是如此。而在 `MultiHeadAttentionWrapper` 中,我们需要对每个注意力头重复执行这一矩阵乘法操作,这种计算方式的开销非常大。
MultiHeadAttention 类的用法与我们之前实现的 SelfAttention 和 CausalAttention 类类似:
```python
torch.manual_seed(123)
batch_size, context_length, d_in = batch.shape
d_out = 2
mha = MultiHeadAttention(d_in, d_out, context_length, 0.0, num_heads=2)
context_vecs = mha(batch)
print(context_vecs)
print("context_vecs.shape:", context_vecs.shape)
```
从结果可以看出,输出维度是由`d_out`参数直接控制的:
```python
tensor([[[0.3190, 0.4858],
[0.2943, 0.3897],
[0.2856, 0.3593],
[0.2693, 0.3873],
[0.2639, 0.3928],
[0.2575, 0.4028]],
[[0.3190, 0.4858],
[0.2943, 0.3897],
[0.2856, 0.3593],
[0.2693, 0.3873],
[0.2639, 0.3928],
[0.2575, 0.4028]]], grad_fn=)
context_vecs.shape: torch.Size([2, 6, 2])
```
在本节中,我们实现了 MultiHeadAttention 类,这将在后续章节实现和训练 LLM 时使用。请注意,虽然代码功能齐全,但我们使用了较小的嵌入维度和注意力头数,以便让输出结果更易于阅读。
作为对比,最小的 GPT-2 模型(1.17 亿参数)具有 12 个注意力头和 768 的上下文向量嵌入大小。而最大的 GPT-2 模型(15 亿参数)则具有 25 个注意力头和 1600 的上下文向量嵌入大小。请注意,在 GPT 模型中,token 输入的嵌入大小与上下文嵌入大小是相同的(`d_in = d_out`)。
> [!NOTE]
>
> **练习 3.3:初始化 GPT-2 规模的注意力模块**
>
> 使用 MultiHeadAttention 类初始化一个多头注意力模块,该模块的注意力头数量与最小的 GPT-2 模型相同(12 个注意力头)。同时确保输入和输出的嵌入大小与 GPT-2 相似(768 维)。请注意,最小的 GPT-2 模型支持的上下文长度为 1024 个 tokens。
## 3.7 本章摘要
+ 注意力机制将输入元素转换为增强的上下文向量表示,其中包含了所有输入的信息。
+ 自注意力机制通过对输入的加权求和来计算上下文向量表示。
+ 在简化的注意力机制中,注意力权重是通过点积计算的。
+ 点积仅仅是对两个向量逐元素相乘后求和的一种简洁方式。
+ 矩阵乘法虽然并不是绝对必要的,但通过替换嵌套的 for 循环,它帮助我们更高效和简洁地计算。
+ 在 LLM 中使用的自注意力机制,也称为缩放点积注意力,我们引入可训练的权重矩阵,以计算输入的中间转换:查询、值和键。
+ 在使用从左到右读取和生成文本的 LLM 时,我们添加一个因果注意力掩码,以防止模型访问未来的 token。
+ 除了通过因果注意力掩码将注意力权重置为零之外,我们还可以添加 dropout 掩码,以减少 LLM 中的过拟合现象。
+ 基于Transformer的 LLM 中的注意力模块包含多个因果注意力实例,这被称为多头注意力。
+ 我们可以通过堆叠多个因果注意力模块的实例来创建一个多头注意力模块。
+ 创建多头注意力模块的一种更高效的方法是采用批量矩阵乘法。
> [!TIP]
>
> **个人思考:** 毫无疑问,本章的注意力机制是整本书中最重要的内容,也是最难的内容 ,这里强烈建议读者能多读几遍,按照文中的示例代码完整地实现一遍,对于不理解的地方多去查阅相关资料及深入思考,力求真正理解和掌握每个细节点。
## /cn-Book/4.ä»Âé¶å¼Âå§Âå®Âç°ä¸Â个ç¨äºÂæÂÂæÂÂÂæÂÂç GPT 模åÂÂ.md
本章涵盖以下内容:
+ **编写一个类 GPT 的大语言模型(LLM),可以训练其生成类人文本(指的是由人工智能模型生成的文本,这些文本在语言表达、语法结构、情感表达等方面与人类自然书写的文本非常相似)**
+ **对网络层的激活值进行归一化,以稳定神经网络的训练过程**
+ **在深度神经网络中添加快捷连接,以更高效地训练模型**
+ **通过实现 Transformer 模块来构建不同规模的 GPT 模型**
+ **计算 GPT 模型的参数数量和存储需求**
-----
- [4.1 实现 LLM 的架构](#41-实现-llm-的架构)
- [4.2 使用层归一化对激活值进行标准化](#42-使用层归一化对激活值进行标准化)
- [4.3 实现带有 GELU 激活函数的前馈神经网络](#43-实现带有-gelu-激活函数的前馈神经网络)
- [4.4 添加快捷连接](#44-添加快捷连接)
- [4.5 在 Transformer 模块中连接注意力层与线性层](#45-在-transformer-模块中连接注意力层与线性层)
- [4.6 实现 GPT 模型](#46-实现-gpt-模型)
- [4.7 生成文本](#47-生成文本)
- [4.8 本章摘要](#48-本章摘要)
-----
在上一章中,我们学习并实现了多头注意力机制,这是大语言模型(LLM)的核心组件之一。本章将进一步实现 LLM 的其他组件,并将它们组装成一个与 GPT 类似结构的模型。我们将在下一章中训练该模型,以生成类人文本,具体过程如图 4.1 所示。
大语言模型(LLM)架构(见图 4.1)由多个模块构成,我们将在本章中实现这些模块。接下来的内容,我们首先从整体视角介绍模型架构,然后详细讲解各个组件。
## 4.1 实现 LLM 的架构
LLM(如GPT,即生成式预训练 Transformer,Generative Pretrained Transformer)是一种大型深度神经网络架构,设计用于逐词(或逐 token)生成新文本。然而,尽管模型规模庞大,其结构却并没有想象中那么复杂,因为模型的许多组件是重复的(后文将对此展开说明)。图 4.2 展示了一个类 GPT 的 LLM 的整体视图,并突出了其主要组成部分。
如图 4.2 所示,我们已经在之前的章节中讲解过几个模块,如输入的分词和嵌入,以及掩码多头注意力模块。本章的重点是实现 GPT 模型的核心结构(包括 Transformer 模块)。我们将在下一章对该模型进行训练,使其能够生成类人文本。
在前几章中,为了简单起见,我们使用了较小的嵌入维度,确保概念和示例能够更方便地展示在一页内。而在本章中,我们将逐步扩展模型规模,达到小型 GPT-2 模型的大小(拥有1.24 亿参数量的最小版本)。该模型在 Radford 等人的论文《Language Models are Unsupervised Multitask Learners.》中有详细介绍。请注意,尽管最初的报告中提到参数量为 1.17 亿,但后来更正为 1.24 亿。
第 6 章将重点介绍如何将预训练权重加载到我们的实现中,并将其调整为更大的 GPT-2 模型版本(包括 3.45 亿、7.62 亿和 15.42 亿参数量规模)。在深度学习和 GPT 等大语言模型的背景下,‘参数’一词指的是模型的可训练权重。这些权重本质上是模型的内部变量,在训练过程中不断调整和优化,以最小化特定的损失函数。这种优化使得模型能够从训练数据中学习。
例如,在一个神经网络层中,其权重由一个 2,048 x 2,048 维的矩阵(或张量)表示,这个矩阵的每个元素都是一个参数。由于该矩阵有 2,048 行和 2,048 列,因此该层的总参数数量为 2,048 乘以 2,048,即 4,194,304 个参数。
> [!NOTE]
>
> **GPT-2 与 GPT-3 的比较**
>
> 我们之所以关注 GPT-2,是因为 OpenAI 已公开了其预训练模型的权重,这些权重将在第 6 章中加载到我们的实现中。GPT-3 的模型架构基本上与 GPT-2 相同,只是将参数规模从 GPT-2 的 15 亿增加到了 1750 亿,同时在更多的数据上进行了训练。截至本文撰写时,GPT-3 的权重尚未公开。对于学习如何实现LLM,GPT-2 是更好的选择,因为它可以在单台笔记本电脑上运行,而 GPT-3 的训练和推理则需要 GPU 集群。根据 Lambda Labs 的估算,在单块 V100 数据中心 GPU 上训练 GPT-3 需要 355 年,而在消费级的 RTX 8000 GPU 上则需要 665 年。
我们通过以下 Python 字典来定义小型 GPT-2 模型的配置,稍后将在代码示例中使用该配置:
```python
GPT_CONFIG_124M = {
"vocab_size": 50257, # Vocabulary size
"context_length": 1024, # Context length
"emb_dim": 768, # Embedding dimension
"n_heads": 12, # Number of attention heads
"n_layers": 12, # Number of layers
"drop_rate": 0.1, # Dropout rate
"qkv_bias": False # Query-Key-Value bias
}
```
在 GPT_CONFIG_124M 字典中,我们使用简明的变量名,以保证清晰且避免代码行过长:
+ `vocab_size`指的是第 2 章中 BPE 分词器使用的 50,257 个词汇的词表大小。
+ `context_length`表示模型所能处理的最大输入 token 数(在第 2 章介绍位置嵌入时讨论过)。
+ `emb_dim`表示嵌入维度,将每个 token 转换为 768 维的向量。
+ `n_layers`指定模型中 Transformer 模块的层数,后续章节将对此详解。
+ `drop_rate`表示 dropout 机制的强度(例如,0.1 表示丢弃 10% 的隐藏单元),用于防止过拟合,具体内容请回顾第 3 章。
+ `qkv_bias` 参数决定是否在多头注意力的查询、键和值的线性层中加入偏置向量。我们最初会禁用该选项,以遵循现代大语言模型的标准,之后在第 6 章加载 OpenAI 预训练的 GPT-2 权重时再重新考虑该设置。
使用上述配置,我们将从本章开始实现一个GPT占位架构(DummyGPTModel),如图4.3所示。这将为我们提供一个全局视图,了解所有组件如何组合在一起,以及在接下来的章节中需要编写哪些其他组件来组装完整的GPT模型架构。
图 4.3 中显示的编号框说明了我们编写最终 GPT 架构所需理解的各个概念的顺序。我们将从第 1 步开始,这是一个我们称之为 DummyGPTModel 的 GPT 占位架构:
```python
# Listing 4.1 A placeholder GPT model architecture class
import torch
import torch.nn as nn
class DummyGPTModel(nn.Module):
def __init__(self, cfg):
super().__init__()
self.tok_emb = nn.Embedding(cfg["vocab_size"], cfg["emb_dim"])
self.pos_emb = nn.Embedding(cfg["context_length"], cfg["emb_dim"])
self.drop_emb = nn.Dropout(cfg["drop_rate"])
self.trf_blocks = nn.Sequential(
*[DummyTransformerBlock(cfg) for _ in range(cfg["n_layers"])]) #A
self.final_norm = DummyLayerNorm(cfg["emb_dim"]) #B
self.out_head = nn.Linear(
cfg["emb_dim"], cfg["vocab_size"], bias=False
)
def forward(self, in_idx):
batch_size, seq_len = in_idx.shape
tok_embeds = self.tok_emb(in_idx)
pos_embeds = self.pos_emb(torch.arange(seq_len, device=in_idx.device))
x = tok_embeds + pos_embeds
x = self.drop_emb(x)
x = self.trf_blocks(x)
x = self.final_norm(x)
logits = self.out_head(x)
return logits
class DummyTransformerBlock(nn.Module): #C
def __init__(self, cfg):
super().__init__()
def forward(self, x): #D
return x
class DummyLayerNorm(nn.Module): #E
def __init__(self, normalized_shape, eps=1e-5): #F
super().__init__()
def forward(self, x):
return x
#A 为 TransformerBlock 设置占位符
#B 为 LayerNorm 设置占位符
#C 一个简单的占位类,后续将被真正的 TransformerBlock 替换
#D 该模块无实际操作,仅原样返回输入
#E 一个简单的占位类,后续将被真正的 DummyLayerNorm 替换
#F 此处的参数仅用于模拟LayerNorm接口
```
此代码中的 DummyGPTModel 类使用 PyTorch 内置的神经网络模块(nn.Module)定义了一个简化版的类 GPT 模型。该类包括 token 嵌入、位置嵌入、dropout、多个 Transformer 模块(DummyTransformerBlock)、最终的层归一化(DummyLayerNorm)以及线性输出层(out_head)。模型配置通过 Python 字典传入,稍后将传入我们之前创建的 GPT_CONFIG_124M 字典。
`forward`方法定义了数据在模型中的流动方式:计算输入索引的 token 嵌入和位置嵌入,应用 dropout,通过 transformer block 处理数据,应用归一化,最后通过线性输出层生成 logits。
上面的代码已经可以正常运行,不过需要先准备输入数据,在本节后面我们会看到运行效果。需要注意的是,目前代码中我们使用了 `DummyLayerNorm` 和 `DummyTransformerBlock` 作为 Transformer 模块和层归一化的占位符,实际的实现会在后续部分详细介绍。
接下来,我们将准备输入数据并初始化一个新的 GPT 模型,以展示它的用法。基于第二章实现的分词器,图 4.4 展示了数据在 GPT 模型中流入和流出的整体流程。
根据图 4.4 的步骤,我们使用第 2 章介绍的 tiktoken 分词器对包含两个文本的批量输入进行分词,以供 GPT 模型使用:
```python
import tiktoken
tokenizer = tiktoken.get_encoding("gpt2")
batch = []
txt1 = "Every effort moves you"
txt2 = "Every day holds a"
batch.append(torch.tensor(tokenizer.encode(txt1)))
batch.append(torch.tensor(tokenizer.encode(txt2)))
batch = torch.stack(batch, dim=0)
print(batch)
```
这两段文本的token ID 如下:
```python
tensor([[ 6109, 3626, 6100, 345], #A
[ 6109, 1110, 6622, 257]])
#A 第一行对应第一段文本,第二行对应第二段文本。
```
接下来,我们初始化一个拥有 1.24 亿参数的 DummyGPTModel 模型实例,并将分词后的数据批量输入到模型中:
```python
torch.manual_seed(123)
model = DummyGPTModel(GPT_CONFIG_124M)
logits = model(batch)
print("Output shape:", logits.shape)
print(logits)
```
模型输出(通常称为logits)如下:
```python
Output shape: torch.Size([2, 4, 50257])
tensor([[[-1.2034, 0.3201, -0.7130, ..., -1.5548, -0.2390, -0.4667],
[-0.1192, 0.4539, -0.4432, ..., 0.2392, 1.3469, 1.2430],
[ 0.5307, 1.6720, -0.4695, ..., 1.1966, 0.0111, 0.5835],
[ 0.0139, 1.6755, -0.3388, ..., 1.1586, -0.0435, -1.0400]],
[[-1.0908, 0.1798, -0.9484, ..., -1.6047, 0.2439, -0.4530],
[-0.7860, 0.5581, -0.0610, ..., 0.4835, -0.0077, 1.6621],
[ 0.3567, 1.2698, -0.6398, ..., -0.0162, -0.1296, 0.3717],
[-0.2407, -0.7349, -0.5102, ..., 2.0057, -0.3694, 0.1814]]],
grad_fn=)
```
输出的张量有两行,每行对应一段文本。每段文本包含 4 个 token,每个 token 是一个 50,257 维的向量,维度大小与分词器的词汇表相同。
嵌入层的维度为 50,257,因为每个维度对应词汇表中的一个唯一 token。在之后的处理中,我们会将这些 50,257 维向量转换回 token ID,然后再解码成单词。
在对 GPT 架构及其输入输出进行了大概介绍之后,接下来的章节中将编写各个占位模块的实现,首先从用真实的层归一化类替换之前代码中的 DummyLayerNorm 开始。
## 4.2 使用层归一化对激活值进行标准化
在训练深度神经网络时,梯度消失或梯度爆炸问题有时会带来挑战。这些问题会导致训练过程不稳定,使得网络难以有效调整权重,也就是说,模型难以找到一组能最小化损失函数的参数。换句话说,模型很难从数据中学习到足够准确的模式,以支持其做出准确的预测或决策。(如果您对神经网络训练或梯度概念不熟悉,可参考附录 A 的 A.4 节《自动微分入门》。但要理解本书内容,不需要对梯度概念有深刻的理解。)
> [!TIP]
>
> **个人思考:** 虽然对文本内容的理解并不需要深度掌握梯度的概念,但如果我们在学习过程中能习惯去发散,往往能帮助我们对所学知识理解的更深刻,下面我们就来聊一下梯度。
>
> 梯度本质上是一个**变化率**,描述了某个值(例如函数输出值)对另一个值(如输入变量)的变化趋势。简单来说,梯度告诉我们在当前位置上,朝哪个方向移动能让某个目标值增加或减少得更快。
>
> 举例:山坡上的爬山者
>
> 假设你站在一座山的某个位置,想要找到最快下山的路线。你会怎么做呢?首先你会注意到山坡的倾斜度(也就是梯度),倾斜越陡的地方,就意味着朝这个方向走可以让你更快地下降海拔。
>
> 在这个例子中:
>
> + **你的当前位置**代表模型当前的参数值。
> + **山坡的倾斜度**就是梯度,表示你在当前位置向下走的快慢和方向。
> + **往斜坡最陡的方向走**相当于使用梯度更新模型参数,使得海拔(也就是损失值)尽快下降。
>
> 而大模型在应用梯度的概念时,首先会设计一个损失函数,用来衡量模型的预测结果与目标结果的差距。在训练过程中,它通过梯度去帮助每个模型参数不断调整来快速减少损失函数的值,从而提高模型的预测精度。
本节中,我们将实现层归一化,以提高神经网络训练的稳定性和效率。
归一化的核心思想是将神经网络层的激活(输出)调整为均值为 0,方差为 1(即单位方差)。这种调整可以加速权重的收敛速度,确保训练过程的一致性和稳定性。正如上一节提到的,在 GPT-2 和现代 Transformer 架构中,层归一化通常应用于多头注意力模块的前后以及最终输出层之前。
在我们用代码实现层归一化之前,先通过图 4.5 了解一下层归一化的工作原理。
我们可以通过以下代码重现图 4.5 中的示例,其中实现了一个具有 5 个输入和 6 个输出的神经网络层,并将其应用于两个输入样本:
```python
torch.manual_seed(123)
batch_example = torch.randn(2, 5) #A
layer = nn.Sequential(nn.Linear(5, 6), nn.ReLU())
out = layer(batch_example)
print(out)
#A 创建2个训练样本,每个样本有5个维度(特征)
```
打印出的张量中,第一行表示第一个输入样本的层输出,第二行表示第二个输入样本的层输出:
```python
tensor([[0.2260, 0.3470, 0.0000, 0.2216, 0.0000, 0.0000],
[0.2133, 0.2394, 0.0000, 0.5198, 0.3297, 0.0000]],
grad_fn=)
```
我们实现的神经网络层包含一个线性层,后接一个非线性激活函数 ReLU,这是神经网络中的标准激活函数。如果你不熟悉 ReLU,只需了解它的作用是将负值设为 0,确保输出层中没有负值。在 GPT 中,我们将使用另一种更复杂的激活函数,后续章节会介绍。
在对这些输出应用层归一化之前,我们先查看其均值和方差:
```python
mean = out.mean(dim=-1, keepdim=True)
var = out.var(dim=-1, keepdim=True)
print("Mean:\n", mean)
print("Variance:\n", var)
```
输出如下:
```python
Mean:
tensor([[0.1324],
[0.2170]], grad_fn=)
Variance:
tensor([[0.0231],
[0.0398]], grad_fn=)
```
以上均值张量的第一行包含第一个输入样本的均值,第二行输出包含第二个输入样本的均值。
在计算均值或方差等操作时使用 `keepdim=True` 参数,可以确保输出张量的维度与输入张量相同,即使该操作通过`dim`参数减少了张量的维度。例如,如果不使用 `keepdim=True`,返回的均值张量将是一个二维向量 `[0.1324, 0.2170]`,而使用 `keepdim=True` 后,返回的张量则会是一个 `2×1` 的矩阵 `[[0.1324], [0.2170]]`。
`dim` 参数用于指定张量中进行统计计算(如均值或方差)的维度,具体如图 4.6 所示。
如图 4.6 所示,对于二维张量(如矩阵),在进行均值或方差计算等操作时,使用 `dim=-1` 等同于使用 `dim=1`,因为 `-1` 指的是张量的最后一个维度,即二维张量中的列。在后续对 GPT 模型加入层归一化时,模型会生成形状为 `[batch_size, num_tokens, embedding_size]` 的三维张量,我们依然可以使用 `dim=-1` 对最后一个维度进行归一化,而无需将 `dim=1` 改为 `dim=2`。
接下来,我们将对之前获得的层输出应用层归一化。该操作包括减去均值,并除以方差的平方根(即标准差):
```python
out_norm = (out - mean) / torch.sqrt(var)
mean = out_norm.mean(dim=-1, keepdim=True)
var = out_norm.var(dim=-1, keepdim=True)
print("Normalized layer outputs:\n", out_norm)
print("Mean:\n", mean)
print("Variance:\n", var)
```
可以看到,归一化后的层输出现在也包含了负值,其均值为零,方差为 1:
```python
Normalized layer outputs:
tensor([[ 0.6159, 1.4126, -0.8719, 0.5872, -0.8719, -0.8719],
[-0.0189, 0.1121, -1.0876, 1.5173, 0.5647, -1.0876]],
grad_fn=)
Mean:
tensor([[2.9802e-08],
[3.9736e-08]], grad_fn=)
Variance:
tensor([[1.],
[1.]], grad_fn=)
```
请注意,输出张量中的值`2.9802e-08`是`2.9802 × 10^-8`的科学记数法表示,用十进制形式表示为`0.0000000298`。这个值虽然非常接近 0,但由于计算机表示数字的精度有限,会产生微小的数值误差,因此不完全等于 0。
为提高可读性,我们可以将 sci_mode 设置为 False,从而关闭张量值的科学计数法显示模式:
```python
torch.set_printoptions(sci_mode=False)
print("Mean:\n", mean)
print("Variance:\n", var)
Mean:
tensor([[ 0.0000],
[ 0.0000]], grad_fn=)
Variance:
tensor([[1.],
[1.]], grad_fn=)
```
在本节内容中,我们已逐步实现并应用了层归一化。现在将这个过程封装到一个 PyTorch 模块中,以便后续在 GPT 模型中使用。
```python
# Listing 4.2 A layer normalization class
class LayerNorm(nn.Module):
def __init__(self, emb_dim):
super().__init__()
self.eps = 1e-5
self.scale = nn.Parameter(torch.ones(emb_dim))
self.shift = nn.Parameter(torch.zeros(emb_dim))
def forward(self, x):
mean = x.mean(dim=-1, keepdim=True)
var = x.var(dim=-1, keepdim=True, unbiased=False)
norm_x = (x - mean) / torch.sqrt(var + self.eps)
return self.scale * norm_x + self.shift
```
以上是对层归一化的具体实现,它作用于输入张量 `x` 的最后一个维度,该维度表示嵌入维度(emb_dim)。变量 `eps` 是一个小常数(epsilon),在归一化过程中加到方差上,以防止出现除零错误。`scale` 和 `shift` 是两个可训练参数(与输入具有相同的维度)。大语言模型(LLM)在训练中会自动调整这些参数,以改善模型在训练任务上的性能。这使得模型能够学习适合数据处理的最佳缩放和偏移方式。
> [!NOTE]
>
> **有偏方差**
>
> 我们在方差计算方法中选择了设置 `unbiased=False`。对于好奇其含义的读者,可以理解为我们在方差公式中直接用样本数 n 作为分母,不使用贝塞尔校正(通常分母使用 n−1 以校正样本方差估计中的偏差)。这种决定会导致所谓的有偏方差估计。对于大语言模型(LLM)来说,其嵌入维度 n 通常非常大,因此使用 n 和 n−1 的差异实际上可以忽略不计。我们选择这种方式是为了确保与 GPT-2 模型的归一化层兼容,并保持与 TensorFlow 的默认行为一致,后者用于实现最初的 GPT-2 模型。这种设置确保我们的方法与第 6 章中将加载的预训练权重兼容。
现在让我们在实践中尝试LayerNorm模块并将其应用于批量输入:
```python
ln = LayerNorm(emb_dim=5)
out_ln = ln(batch_example)
mean = out_ln.mean(dim=-1, keepdim=True)
var = out_ln.var(dim=-1, unbiased=False, keepdim=True)
print("Mean:\n", mean)
print("Variance:\n", var)
```
结果表明,层归一化代码运行正常,将两个输入的均值归一化为 0,方差归一化为 1:
```python
Mean:
tensor([[ -0.0000],
[ 0.0000]], grad_fn=)
Variance:
tensor([[1.0000],
[1.0000]], grad_fn=)
```
在本节中,我们介绍了实现 GPT 架构所需的一个基础模块(`LayerNorm`),如图 4.7 所示。
在下一节中,我们将探讨大语言模型中使用的 GELU 激活函数,它将替代我们在本节使用的传统 ReLU 函数。
> [!NOTE]
>
> **层归一化与批量归一化的区别**
>
> 如果你熟悉批量归一化这种常见的传统神经网络归一化方法,可能会好奇它与层归一化的区别。与在数量维度上进行归一化的批量归一化不同,层归一化是在特征维度上进行归一化。LLM 通常需要大量计算资源,而可用的硬件资源或特定的使用场景可能会限制训练或推理过程中的批量大小。由于层归一化对每个输入的处理不依赖批量大小,因此在这些场景下提供了更高的灵活性和稳定性。这对于分布式训练或资源受限的环境中部署模型尤其有利。
## 4.3 实现带有 GELU 激活函数的前馈神经网络
在本节中,我们将实现一个小型神经网络子模块,作为 LLM 架构中的 Transformer 模块的一部分。我们首先实现 GELU 激活函数,它将在这个神经网络子模块中起着至关重要的作用。(关于在 PyTorch 中实现神经网络的更多信息,请参考附录 A 的 A.5 节:实现多层神经网络)
过去,ReLU 激活函数因其简单且有效,常用于各种神经网络架构中。但在大语言模型中,除了传统的 ReLU,还使用了其他几种激活函数,其中两个典型的例子是 GELU(高斯误差线性单元)和 SwiGLU(Swish 门控线性单元)。
GELU 和 SwiGLU 是更复杂、平滑的激活函数,分别结合了高斯分布和 sigmoid 门控线性单元。与较简单的 ReLU 不同,这些激活函数能为深度学习模型提供更好的性能。
GELU 激活函数可以通过多种方式实现,其确切版本定义为 `GELU(x) = x ⋅ Φ(x)`,其中 Φ(x) 是标准正态分布的累积分布函数。然而在实践中,通常会采用计算开销更低的近似实现(最初的 GPT-2 模型也是用这种近似实现进行训练的):
$$ \text{GELU}(x) \approx 0.5 \cdot x \cdot\left(1+\tanh \left[\sqrt{(2 / \pi)} \cdot\left(x+0.044715 \cdot x^{3}\right]\right)\right. $$
我们可以编码将该函数实现为一个 PyTorch 模块,如下所示:
```python
# Listing 4.3 An implementation of the GELU activation function
class GELU(nn.Module):
def __init__(self):
super().__init__()
def forward(self, x):
return 0.5 * x * (1 + torch.tanh(
torch.sqrt(torch.tensor(2.0 / torch.pi)) *
(x + 0.044715 * torch.pow(x, 3))
))
```
接下来,为了更直观的观察 GELU 函数的形状,并与 ReLU 函数进行对比,我们将这两个函数并排绘制:
```python
import matplotlib.pyplot as plt
gelu, relu = GELU(), nn.ReLU()
x = torch.linspace(-3, 3, 100) #A
y_gelu, y_relu = gelu(x), relu(x)
plt.figure(figsize=(8, 3))
for i, (y, label) in enumerate(zip([y_gelu, y_relu], ["GELU", "ReLU"]), 1):
plt.subplot(1, 2, i)
plt.plot(x, y)
plt.title(f"{label} activation function")
plt.xlabel("x")
plt.ylabel(f"{label}(x)")
plt.grid(True)
plt.tight_layout()
plt.show()
#A 在 -3 到 3 的范围内生成 100 个样本数据点
```
如图 4.8 所示,ReLU 是一个分段线性函数,输入为正时输出输入值本身,否则输出零。而 GELU 是一种平滑的非线性函数,它近似于 ReLU,但在负值上也具有非零梯度。
如图 4.8 所示,GELU 的平滑性使其在训练过程中具有更好的优化特性,能够对模型参数进行更细微的调整。相比之下,ReLU 在零点处有一个拐角,这在网络深度较大或结构复杂时可能会增加优化难度。此外,ReLU 对所有负输入的输出为零,而 GELU 对负值允许一个小的非零输出。这意味着在训练过程中,接收负输入的神经元也能对学习过程产生一定的贡献,尽管贡献程度不及正输入。
接下来让我们使用 GELU 激活函数实现一个小型的神经网络模块 FeedForward,该模块稍后会应用在 LLM 的 Transformer 模块中:
```python
# Listing 4.4 A feed forward neural network module
class FeedForward(nn.Module):
def __init__(self, cfg):
super().__init__()
self.layers = nn.Sequential(
nn.Linear(cfg["emb_dim"], 4 * cfg["emb_dim"]),
GELU(),
nn.Linear(4 * cfg["emb_dim"], cfg["emb_dim"]),
)
def forward(self, x):
return self.layers(x)
```
如代码所示,FeedForward 模块是一个小型神经网络,由两个线性层和一个 GELU 激活函数组成。在 1.24 亿参数的 GPT 模型中,该模块可以接收批量输入,每个输入 token 是一个 768 维的向量表示。这一嵌入维度大小通过 `GPT_CONFIG_124M` 配置字典中的 `GPT_CONFIG_124M["emb_dim"]` 参数指定。
图 4.9 展示了当我们输入数据后,这个前馈网络内部如何调整嵌入维度。
按照图 4.9 中的示例,我们初始化一个新的 FeedForward 模块,设置 token 嵌入维度为 768,并输入一个包含 2 个样本且每个样本有 3 个 token 的数据集:
```python
ffn = FeedForward(GPT_CONFIG_124M)
x = torch.rand(2, 3, 768) #A
out = ffn(x)
print(out.shape)
#A 创建一个 batch 大小为 2 的示例输入
```
显然,输出张量的形状与输入张量相同:
```python
torch.Size([2, 3, 768])
```
我们在本节实现的 FeedForward 模块对模型能力的增强(主要体现在从数据中学习模式并泛化方面)起到了关键作用。尽管该模块的输入和输出维度相同,但在内部,它首先通过第一个线性层将嵌入维度扩展到一个更高维度的空间(如图 4.10 所示)。之后再接入非线性 GELU 激活,最后再通过第二个线性层变换回原始维度。这样的设计能够探索更丰富的表示空间。
> [!TIP]
>
> **个人思考:** 这段描述一笔带过了扩展和收缩嵌入维度为模型训练带来的好处,那到底该如何理解这样的设计能够探索更丰富的表示空间呢?
>
> 可以将扩展和收缩的过程类比为一种**数据解压缩与重新压缩**的机制:
>
> + **扩展(解压缩)**:假设我们有一段压缩的音乐文件(例如 MP3),里面包含了音频的基本信息。通过解压缩(扩展),我们把这个文件变成了一个更高质量的音频格式,允许我们看到(听到)更多的细节,比如乐器的细微声响和音调变化。
> + **特征提取**:接着,我们可以在这个高质量的音频文件中应用各种音频处理算法(相当于非线性激活),分析出更多细节,比如每种乐器的声音特点。
> + **收缩(压缩)**:最后,我们将音频再次压缩为一种更适合传输和存储的格式。虽然最终文件变小了,但这个文件已经包含了之前提取出的更多的声音细节。
>
> 将这种理解再应用到神经网络中,扩展后的高维空间可以让模型“看到”输入数据中更多的隐藏特征,提取出更丰富的信息。然后在收缩回低维度时,这些丰富的特征被整合到了输入的原始维度表示中,使模型最终的输出包含更多的上下文和信息。
此外,输入输出维度保持一致也有助于简化架构,方便堆叠多层(在后续的章节实现),无需调整各层维度,从而提升了模型的可扩展性。
如图4.11所示,我们目前已经实现了LLM 架构中的大部分模块。
下一节,我们将介绍“快捷连接”的概念,即在神经网络的不同层之间插入的连接结构,它对于提升深度神经网络架构的训练性能非常重要。
## 4.4 添加快捷连接
接下来,我们来讨论快捷连接(也称跳跃连接或残差连接)的概念。快捷连接最初是在计算机视觉中的深度网络(尤其是残差网络)提出的,用于缓解梯度消失问题。梯度消失是指在训练中指导权重更新的梯度在反向传播过程中逐渐减小,导致早期层(靠近输入端的网络层)难以有效训练,如图 4.12 所示。
如图 4.12 所示,快捷连接通过跳过一层或多层,为梯度提供一条更短的流动路径,这是通过将某层的输出加到后续层的输出上来实现的。因此,这种连接方式也称为跳跃连接。在反向传播中,快捷连接对保持梯度流动至关重要。
在以下代码示例中,我们将实现图 4.12 中所示的神经网络,以展示如何在前向传播方法中添加快捷连接:
```python
# Listing 4.5 A neural network to illustrate shortcut connections
class ExampleDeepNeuralNetwork(nn.Module):
def __init__(self, layer_sizes, use_shortcut):
super().__init__()
self.use_shortcut = use_shortcut
self.layers = nn.ModuleList([
# Implement 5 layers
nn.Sequential(nn.Linear(layer_sizes[0], layer_sizes[1]), GELU()),
nn.Sequential(nn.Linear(layer_sizes[1], layer_sizes[2]), GELU()),
nn.Sequential(nn.Linear(layer_sizes[2], layer_sizes[3]), GELU()),
nn.Sequential(nn.Linear(layer_sizes[3], layer_sizes[4]), GELU()),
nn.Sequential(nn.Linear(layer_sizes[4], layer_sizes[5]), GELU())
])
def forward(self, x):
for layer in self.layers:
# Compute the output of the current layer
layer_output = layer(x)
# Check if shortcut can be applied
if self.use_shortcut and x.shape == layer_output.shape:
x = x + layer_output
else:
x = layer_output
return x
```
以上代码实现了一个 5 层的深度神经网络,每层包括一个线性层和 GELU 激活函数。在前向传播中,我们将输入逐层传递,同时如果 `self.use_shortcut` 属性设置为 True,则会添加图 4.12 所示的快捷连接。
我们将使用以下代码初始化一个没有快捷连接的神经网络,其中每一层都被初始化为接受 3 个输入值并返回 3 个输出值。最后一层则返回一个单一的输出值:
```python
layer_sizes = [3, 3, 3, 3, 3, 1]
sample_input = torch.tensor([[1., 0., -1.]])
torch.manual_seed(123) # specify random seed for the initial weights for reproducibility
model_without_shortcut = ExampleDeepNeuralNetwork(
layer_sizes, use_shortcut=False
)
```
接下来,我们实现一个用于在模型反向传播过程中计算梯度的函数:
```python
def print_gradients(model, x):
# Forward pass
output = model(x)
target = torch.tensor([[0.]])
# Calculate loss based on how close the target
# and output are
loss = nn.MSELoss()
loss = loss(output, target)
# Backward pass to calculate the gradients
loss.backward()
for name, param in model.named_parameters():
if 'weight' in name:
# Print the mean absolute gradient of the weights
print(f"{name} has gradient mean of {param.grad.abs().mean().item()}")
```
上述代码中,我们定义了一个损失函数,用来计算模型输出与用户指定目标(此处为简单起见,目标值设为 0)之间的差距。接着,当调用 `loss.backward()` 时,PyTorch 会为模型的每一层计算损失的梯度。我们可以通过 `model.named_parameters()` 遍历权重参数。假设某层的权重参数是一个 3×3 的矩阵,那么这一层会有 3×3 的梯度值。然后我们打印出这 3×3 梯度值的绝对均值,以便得到每层的单一梯度值,从而更容易比较各层之间的梯度大小。
简而言之,`.backward()` 是 PyTorch 中一个用于自动计算损失梯度的便捷方法,这在模型训练过程中很重要。它让我们无需亲自实现梯度计算的数学过程,从而大大简化了深度神经网络的开发过程。如果您对梯度和神经网络训练不熟悉,建议参考附录 A 中的 A.4 节:**轻松实现自动微分** 和 A.7 节:**典型的训练循环**。
现在让我们使用 `print_gradients` 函数,并将其应用到没有跳跃连接的模型上:
```python
print_gradients(model_without_shortcut, sample_input)
```
输出如下:
```python
layers.0.0.weight has gradient mean of 0.00020173587836325169
layers.1.0.weight has gradient mean of 0.0001201116101583466
layers.2.0.weight has gradient mean of 0.0007152041653171182
layers.3.0.weight has gradient mean of 0.001398873864673078
layers.4.0.weight has gradient mean of 0.005049646366387606
```
从 `print_gradients` 函数的输出可以看出,梯度在从最后一层(layers.4)到第一层(layers.0)时逐渐减小,这种现象称为梯度消失问题。
我们再来创建一个带有跳跃连接的模型,看看它的表现如何:
```python
torch.manual_seed(123)
model_with_shortcut = ExampleDeepNeuralNetwork(
layer_sizes, use_shortcut=True
)
print_gradients(model_with_shortcut, sample_input)
```
输出如下:
```python
layers.0.0.weight has gradient mean of 0.22169792652130127
layers.1.0.weight has gradient mean of 0.20694105327129364
layers.2.0.weight has gradient mean of 0.32896995544433594
layers.3.0.weight has gradient mean of 0.2665732502937317
layers.4.0.weight has gradient mean of 1.3258541822433472
```
从输出结果可以看到,最后一层(layers.4)的梯度依然比其他层更大。然而,随着接近第一层(layers.0),梯度值逐渐趋于稳定,并未缩小到几乎消失的程度。
总之,快捷连接在解决深度神经网络中的梯度消失问题方面具有重要作用。作为 LLM 的核心构建单元,快捷连接可以确保各层之间的梯度稳定流动,从而帮助 GPT 模型更有效的训练(下一章实现训练过程)。
在介绍了快捷连接后,我们将在下一节把之前讲解的所有概念(层归一化、GELU 激活、前馈网络模块和快捷连接)整合进一个 Transformer 模块中,这是构建 GPT 架构所需的最后一个模块。
> [!TIP]
>
> **个人思考:** 看到这里,不知各位读者是否真正理解了快捷连接在深度神经网络中的作用,这里其实涉及到快捷连接的两个重要的作用:
>
> + **保持信息(或者说是特征)流畅传递**
> + **缓解梯度消失问题**
>
> 让我们逐一解读,LLM 中的每个Transformer 模块通常包含两个重要组件(**可以先阅读完4.5节,再回头看这里的解读**):
>
> 1. **自注意力层(Self-Attention Layer)**:计算每个 token 与其他 token 的关联,帮助模型理解上下文。
> 2. **前馈网络(Feed Forward Network)**:对每个 token 的嵌入(embedding)进行进一步的非线性转换,使模型能够提取更复杂的特征。
>
> 这两个部分都在层归一化(Layer Normalization)和快捷连接(Shortcut Connections)的配合下工作。
>
> 假设我们正在训练一个 LLM ,并希望它理解下面的句子:
>
> `The cat sat on the mat because it was tired.`
>
> 模型需要通过多个 Transformer 层来逐层处理该句子,使得每个词(token)在上下文中能被理解。为了达到这一目的,每个 token 的嵌入会在多层中进行注意力计算和前馈网络处理。
>
> 1. **没有快捷连接时的情况**
>
> 如果没有快捷连接,那么每个 Transformer 层的输出就直接传递到下一个层。这种情况下,网络中的信息流大致如下:
>
> + **层间信息传递的局限**:假设当前层的注意力机制计算出了“it”和“cat”之间的关系,如果前馈网络进一步转换了这个信息,那么下一层就只能基于该层的输出,可能丢失一些最初的语义信息。
> + **梯度消失**:在训练过程中,梯度从输出层逐层向回传播。如果层数过多,梯度会逐渐变小(即“梯度消失”),从而导致模型难以有效更新前面层的参数。
>
> 这种情况下,由于信息不能直接流动到更深层次的网络,可能会导致模型难以有效捕捉到前层的一些原始信息。
>
> 2. **加入快捷连接后的情况**
>
> 加入快捷连接后,信息可以在层与层之间**直接跳跃**。例如,假设在第 n 层,我们有输入 Xn,经过注意力和前馈网络得到输出F(Xn)。加入快捷连接后,这一层的输出可以表示为:
>
> $$\text { 输出 }=X_{n}+F\left(X_{n}\right)$$
>
> 这意味着第 n 层的输出不仅包含了这一层的新信息 F(Xn),还保留了原始输入 Xn 的信息。下面是这样做的好处:
>
> - **保留原始信息**
>
> 快捷连接让输入的原始信息直接传递到后续层,避免了在多层处理过程中丢失重要信息。例如,“it” 和 “cat” 之间的关系在较浅层中被捕捉到后,即使后面的层有进一步的处理,模型依然能够从快捷连接中获得最初的上下文信息。
>
> - **减轻梯度消失**
>
> 假设我们有一个简单的三层网络,第三层的输出 O 是整个网络的输出。我们从损失函数 LLL 开始计算梯度:
>
> - 根据反向传播的原理,**无快捷连接**时,梯度必须逐层传递,如下:
>
> $$\frac{\partial L}{\partial X_{1}}=\frac{\partial L}{\partial X_{3}} \cdot \frac{\partial X_{3}}{\partial X_{2}} \cdot \frac{\partial X_{2}}{\partial X_{1}}$$
>
> 这里,如果某一层的梯度值很小,那么梯度会被逐层缩小,导致梯度消失。
>
> - **有快捷连接**时,假设我们在每一层之间都添加快捷连接,梯度的传播路径就多了一条直接路径:
>
> $$\frac{\partial L}{\partial X_{1}}=\frac{\partial L}{\partial\left(X_{1}+F\left(X_{1}\right)\right)} \cdot\left(1+\frac{\partial F\left(X_{1}\right)}{\partial X_{1}}\right)$$
>
> 这样,即使 $` \frac{\partial F\left(X_{1}\right)}{\partial X_{1}} `$ 很小,梯度依然可以通过 111 这条路径直接传递到更前面的层。
## 4.5 在 Transformer 模块中连接注意力层与线性层
本节我们将实现 Transformer 模块,它是 GPT 和其他大语言模型架构的基本模块。这个在 124M 参数的 GPT-2 架构中重复了十几次的模块,结合了多头注意力、层归一化、dropout、前馈层和 GELU 激活等多个概念,详见图 4.13。在下一节中,我们将把这个 Transformer 模块连接到 GPT 架构的其余部分。
如图 4.13 所示,Transformer 模块结合了多个组件,包括第 3 章中的掩码多头注意力模块以及我们在 4.3 节中实现的前馈网络模块。
当 Transformer 模块处理输入序列时,序列中的每个元素(如单词或子词 token)都会被表示为固定大小的向量(如图 4.13 中为 768 维)。Transformer 模块中的操作,包括多头注意力和前馈层,旨在以维度不变的方式对这些向量进行转换。
之所以这样设计,是因为多头注意力模块中的自注意力机制用于识别和分析输入序列中各元素之间的关系,而前馈神经网络则对输入序列中每个位置的数据单独进行修改。这种组合不仅能够更细致地理解和处理输入信息,还增强了模型处理复杂数据模式的整体能力。
可以通过以下代码实现 Transformer 模块:
```python
# Listing 4.6 The transformer block component of GPT
from previous_chapters import MultiHeadAttention
class TransformerBlock(nn.Module):
def __init__(self, cfg):
super().__init__()
self.att = MultiHeadAttention(
d_in=cfg["emb_dim"],
d_out=cfg["emb_dim"],
context_length=cfg["context_length"],
num_heads=cfg["n_heads"],
dropout=cfg["drop_rate"],
qkv_bias=cfg["qkv_bias"])
self.ff = FeedForward(cfg)
self.norm1 = LayerNorm(cfg["emb_dim"])
self.norm2 = LayerNorm(cfg["emb_dim"])
self.drop_shortcut = nn.Dropout(cfg["drop_rate"])
def forward(self, x):
shortcut = x #A
x = self.norm1(x)
x = self.att(x)
x = self.drop_shortcut(x)
x = x + shortcut # Add the original input back
shortcut = x #B
x = self.norm2(x)
x = self.ff(x)
x = self.drop_shortcut(x)
x = x + shortcut #C
return x
#A 注意力模块中的快捷连接
#B 前馈网络模块中的快捷链接
#C 将原始输入加回到输出中
```
给定的代码在 PyTorch 中定义了一个 TransformerBlock 类,包含多头注意力机制(MultiHeadAttention)和前馈网络(FeedForward),并根据提供的配置字典(cfg)进行配置,例如前文定义的 GPT_CONFIG_124M。
层归一化(LayerNorm)在这两个组件(即自注意力和前馈网络)之前应用,而 dropout 则在它们之后应用,用于正则化模型并防止过拟合。这种方式也称为前置层归一化(Pre-LayerNorm)。而在早期的架构中(如原始的 Transformer 模型),一般将层归一化应用在自注意力和前馈网络之后,这被称为后置层归一化(Post-LayerNorm),这种方式通常会导致较差的训练效果。
该类还实现了前向传播(`forward方法`),其中每个组件后面都设有一个快捷连接,将对应组件的输入添加到输出中。这一关键特性有助于在训练过程中促进梯度流动,从而提升 LLM 的学习能力,相关内容详见第 4.4 节。
现在使用我们之前定义的 `GPT_CONFIG_124M` 配置字典,实例化一个 Transformer 模块,并向其中输入一些示例数据:
```python
torch.manual_seed(123)
x = torch.rand(2, 4, 768) #A
block = TransformerBlock(GPT_CONFIG_124M)
output = block(x)
print("Input shape:", x.shape)
print("Output shape:", output.shape)
#A 建一个形状为 [batch_size, num_tokens, emb_dim] 的输入张量
```
以上代码输出如下:
```python
Input shape: torch.Size([2, 4, 768])
Output shape: torch.Size([2, 4, 768])
```
从代码输出可以看出,Transformer 模块的输出维度与输入维度保持一致,这说明 Transformer 架构在处理序列数据时不会改变数据的形状。
Transformer 模块结构中保持数据形状不变并非偶然,而是其设计的一个关键特性。这种设计使 Transformer 擅长处理各种序列到序列任务,因为每个输出向量直接对应一个输入向量,保持一一对应关系。然而,虽然维度一致,但输出向量是包含整个输入序列信息的“上下文向量”。也就是说,尽管序列的物理维度(长度和特征维度)在经过 Transformer 模块时保持不变,但每个输出向量的内容会被重新编码,融合整个输入序列的上下文信息。
在本节完成了 Transformer 模块的实现后,我们已经具备了实现 GPT 架构所需的全部基础模块(如图 4.14 所示)。
如图 4.14 所示,Transformer 模块由层归一化、带有 GELU 激活函数的前馈网络和快捷连接组成,这些内容在本章前面已经讨论过。正如我们将在接下来的章节中看到的,这个 Transformer 模块将构成我们要实现的 GPT 架构的核心部分。
## 4.6 实现 GPT 模型
截止到目前,本章已初步实现了一个名为`DummyGPTModel`类的GPT架构,在该`DummyGPTModel`的代码实现中,我们展示了 GPT 模型的输入和输出形式,但其内部的一些核心模块仅仅使用了`DummyTransformerBlock`和`DummyLayerNorm`等类来占位,并未替换成真正的实现。
在本节中,我们将 DummyTransformerBlock 和 DummyLayerNorm 占位符替换为本章后面实现的真实 TransformerBlock 和 LayerNorm 类,以组装出一个完整可用的原始 GPT-2 模型(124M 参数版本)。在第 5 章,我们将预训练一个 GPT-2 模型,第 6 章则会加载 OpenAI 的预训练权重。
在我们通过代码构建 GPT-2 模型之前,先通过图 4.15 看一下模型的整体结构,该结构结合了本章目前为止介绍的所有概念。
如图 4.15 所示,我们在 4.5 节中编写的 Transformer 模块在 GPT 架构中会重复多次。在参数量为 1.24 亿的 GPT-2 模型中,该模块重复了 12 次,这一数量通过 `GPT_CONFIG_124M` 配置字典中的`n_layers`参数指定。在 GPT-2 最大的 15.42 亿参数模型中,Transformer 模块重复了 36 次。
我们还可以从图 4.15 中得知,最后一个 Transformer 模块的输出会经过一个最终的层归一化步骤,然后进入线性输出层。该层将 Transformer 的输出映射到一个高维空间(在本例中为 50,257 维,对应于模型的词汇表大小),以预测序列中的下一个词。
接下来我们用代码实现图 4.15 中的架构:
```python
# Listing 4.7 The GPT model architecture implementation
class GPTModel(nn.Module):
def __init__(self, cfg):
super().__init__()
self.tok_emb = nn.Embedding(cfg["vocab_size"], cfg["emb_dim"])
self.pos_emb = nn.Embedding(cfg["context_length"], cfg["emb_dim"])
self.drop_emb = nn.Dropout(cfg["drop_rate"])
self.trf_blocks = nn.Sequential(
*[TransformerBlock(cfg) for _ in range(cfg["n_layers"])])
self.final_norm = LayerNorm(cfg["emb_dim"])
self.out_head = nn.Linear(
cfg["emb_dim"], cfg["vocab_size"], bias=False
)
def forward(self, in_idx):
batch_size, seq_len = in_idx.shape
tok_embeds = self.tok_emb(in_idx)
pos_embeds = self.pos_emb(torch.arange(seq_len, device=in_idx.device)) #A
x = tok_embeds + pos_embeds
x = self.drop_emb(x)
x = self.trf_blocks(x)
x = self.final_norm(x)
logits = self.out_head(x)
return logits
#A 设备设置将根据输入数据所在的位置选择在 CPU 或 GPU 上训练模型
```
通过代码可以看出,由于我们已经在 4.5 节实现了 `TransformerBlock` 类,从而使得 `GPTModel` 类的设计更为简洁。
`GPTModel` 类的构造函数 `__init__` 使用字典 `cfg` 中的配置参数初始化 token 嵌入层和位置嵌入层。这些嵌入层负责将输入的 token 索引转换为密集向量并加入位置信息(在第 2 章已讨论过)。
接下来,`__init__` 方法会根据 `cfg` 中指定的层数创建一个由 TransformerBlock 模块组成的顺序堆栈。紧接在 TransformerBlock 堆栈之后应用一个 LayerNorm 层,对其输出进行标准化,从而稳定训练过程。最后,定义了一个无偏置的线性输出层,将 Transformer 的输出投射到分词器的词汇空间,为词汇表中的每个 token 生成对应的 logits。
forward 方法则负责接收一批 token 索引作为输入,计算它们的词嵌入向量,并应用位置嵌入,接着将序列通过 Transformer 模块进行处理,对最终的输出进行归一化,最后计算 logits 来表示下一个 token 的非归一化概率。我们将在下一节将这些 logits 转换为 token 和文本输出。
现在让我们使用 `GPT_CONFIG_124M` 字典配置来初始化一个具有 1.24 亿参数的 GPT 模型,并将本章开头创建的批量文本作为模型输入。
```python
torch.manual_seed(123)
model = GPTModel(GPT_CONFIG_124M)
out = model(batch)
print("Input batch:\n", batch)
print("\nOutput shape:", out.shape)
print(out)
```
上面的代码依次打印了输入批次的内容和输出张量:
```python
Input batch:
tensor([[ 6109, 3626, 6100, 345], # token IDs of text 1
[ 6109, 1110, 6622, 257]]) # token IDs of text 2
Output shape: torch.Size([2, 4, 50257])
tensor([[[ 0.3613, 0.4222, -0.0711, ..., 0.3483, 0.4661, -0.2838],
[-0.1792, -0.5660, -0.9485, ..., 0.0477, 0.5181, -0.3168],
[ 0.7120, 0.0332, 0.1085, ..., 0.1018, -0.4327, -0.2553],
[-1.0076, 0.3418, -0.1190, ..., 0.7195, 0.4023, 0.0532]],
[[-0.2564, 0.0900, 0.0335, ..., 0.2659, 0.4454, -0.6806],
[ 0.1230, 0.3653, -0.2074, ..., 0.7705, 0.2710, 0.2246],
[ 1.0558, 1.0318, -0.2800, ..., 0.6936, 0.3205, -0.3178],
[-0.1565, 0.3926, 0.3288, ..., 1.2630, -0.1858, 0.0388]]],
grad_fn=)
```
可以看到,输出张量的形状是 [2, 4, 50257],这是因为我们输入了 2 个文本,每个文本包含 4 个 token。最后一个维度 50257 对应于分词器的词汇表大小。在下一节中,我们将看到如何将这些 50257 维的输出向量转换回 token。
在我们继续后续内容并编写将模型输出转换为文本的函数之前,让我们先花点时间研究一下模型架构本身,并分析其规模。
使用 numel() 方法(即 `number of elements` 的缩写),可以统计模型中参数张量的总参数量:
```python
total_params = sum(p.numel() for p in model.parameters())
print(f"Total number of parameters: {total_params:,}")
```
输出如下:
```python
Total number of parameters: 163,009,536
```
细心的读者可能会发现一个差异:我们之前提到 GPT 模型的参数量为 1.24 亿,但代码输出的实际参数量却是 1.63 亿,这是为什么呢?
原因在于 GPT-2 架构中使用了一种称为‘权重共享’的概念,这意味着 GPT-2 架构将 token 嵌入层的权重复用于输出层。为了更好地理解这一点,我们可以来看一下在模型中初始化的 token 嵌入层和线性输出层的形状:
```python
print("Token embedding layer shape:", model.tok_emb.weight.shape)
print("Output layer shape:", model.out_head.weight.shape)
```
从打印结果可以看到,这两层的权重形状相同:
```python
Token embedding layer shape: torch.Size([50257, 768])
Output layer shape: torch.Size([50257, 768])
```
token 嵌入层和输出层的参数量很大,因为分词器词汇表中包含 50,257 个 token。根据权重共享原则,我们可以从 GPT-2 模型的总参数量中去除输出层的参数量。
```python
total_params_gpt2 = total_params - sum(p.numel() for p in model.out_head.parameters())
print(f"Number of trainable parameters considering weight tying: {total_params_gpt2:,}")
```
输出如下:
```python
Number of trainable parameters considering weight tying: 124,412,160
```
如我们所见,模型现在的参数量为 1.24 亿,与 GPT-2 原始模型的规模一致。
权重共享能够减少模型的整体内存占用和计算复杂度。然而,根据我的经验,分别使用独立的 token 嵌入层和输出层会使训练效果和模型性能更佳,因此在我们的 GPT 模型实现中,我们使用了独立的嵌入层和输出层。现代大语言模型也是如此。不过,在第 6 章加载 OpenAI 的预训练权重时,我们会再次探讨并实现权重共享的概念。
> [!NOTE]
>
> **练习 4.1 前馈网络和注意力模块的参数数量**
>
> 计算并比较前馈模块和多头注意力模块中包含的参数数量。
最后,让我们来计算 GPTModel 对象中 1.63 亿参数所需的内存:
```python
total_size_bytes = total_params * 4 #A
total_size_mb = total_size_bytes / (1024 * 1024) #B
print(f"Total size of the model: {total_size_mb:.2f} MB")
#A 计算参数总大小(假设每个参数为 float32 类型,占用 4 字节)
#B 转换为 MB
```
输出如下:
```python
Total size of the model: 621.83 MB
```
通过计算 GPTModel 中 1.63 亿个参数所需的内存,并假设每个参数为 32 位浮点数,占用 4 字节,我们得出模型总大小为 621.83 MB。这表明,即使是相对较小的大语言模型也需要较大的存储空间。
在本节中,我们实现了 GPTModel 架构,并观察到它的输出是形状为 [batch_size, num_tokens, vocab_size] 的数值张量。接下来,我们将编写代码把这些输出张量转换为文本。
> [!NOTE]
>
> **练习 4.2 初始化大型 GPT 模型**
>
> 本章中,我们初始化了一个拥有 1.24 亿参数的 GPT 模型,即 GPT-2 small。请在不更改代码的情况下(仅更新配置文件),使用 `GPTModel` 类实现 GPT-2 medium(1024 维嵌入、24 层 Transformer 块、16 个多头注意力头)、GPT-2 large(1280 维嵌入、36 层 Transformer 块、20 个多头注意力头)和 GPT-2 XL(1600 维嵌入、48 层 Transformer 块、25 个多头注意力头)。作为附加任务,请计算每个 GPT 模型的总参数量。
## 4.7 生成文本
在本章的最后一节,我们将编写代码把 GPT 模型的张量输出转回文本。在开始之前,我们先简要回顾一下像 LLM 这样的生成模型是如何逐词生成文本的,如图 4.16 所示。
如图 4.16 所示,GPT 模型在给定输入上下文(例如 ‘Hello, I am’)后,逐步生成文本。每次迭代中,输入上下文会不断扩展,使模型能够生成连贯且符合上下文的内容。在第 6 次迭代时,模型已构建出完整句子 ‘Hello, I am a model ready to help.’。
在上一节,我们看到目前的 GPTModel 输出的张量形状为 `[batch_size, num_token, vocab_size]`。那么问题来了,GPT 模型是如何将这些输出张量转化为图 4.16 所示的生成文本的呢?
GPT 模型从输出张量到生成文本的过程涉及几个步骤(如图 4.17 所示)。这些步骤包括解码输出张量、根据概率分布选择 token,并将其转化为可读文本。
图 4.17 详细展示了 GPT 模型根据输入生成下一个 token 的单步过程。
在每一步,模型会输出一个矩阵,其中的向量表示潜在的下一个 token。取出对应于下一个 token 的向量,并通过 softmax 函数将其转换为概率分布。在包含概率分数的向量中,找到最高值的索引,并将其转换为 token ID。将该 token ID 解码回文本,得到序列中的下一个 token。最后,将该 token 添加到先前的输入中,形成下一次迭代的新输入序列。这种逐步生成的过程使模型能够根据初始输入上下文,按顺序生成文本,从而构建出连贯的短语和句子。
在实践中,我们会多次迭代这一过程(如前文图 4.16 所示),直到生成的 token 数量达到用户指定值。
我们通过以下代码来实现上述的 token 生成过程:
```python
# Listing 4.8 A function for the GPT model to generate text
def generate_text_simple(model, idx, max_new_tokens, context_size): #A
for _ in range(max_new_tokens):
idx_cond = idx[:, -context_size:] #B
with torch.no_grad():
logits = model(idx_cond)
logits = logits[:, -1, :] #C
probas = torch.softmax(logits, dim=-1) #D
idx_next = torch.argmax(probas, dim=-1, keepdim=True) #E
idx = torch.cat((idx, idx_next), dim=1) #F
return idx
#A idx 是当前上下文中索引的数组,形状为 (batch, n_tokens)
#B 若上下文长度超出支持范围,则进行裁剪。例如,若模型仅支持 5 个 token,而上下文长度为 10,仅使用最后 5 个 token 作为上下文
#C 仅关注最后一个时间步,将形状从 (batch, n_token, vocab_size) 转换为 (batch, vocab_size)
#D probas 的形状为 (batch, vocab_size)
#E idx_next 的形状为 (batch, 1)
#F 将采样的索引追加到当前序列中,此时 idx 的形状为 (batch, n_tokens+1)
```
在上述代码中,`generate_text_simple` 函数使用 Softmax 函数将 logits 转换为概率分布,然后通过 `torch.argmax` 找出概率最高的位置。Softmax 函数是单调的,这意味着它会保持输入的相对顺序,因此,Softmax 这一步实际上是冗余的,因为 Softmax 输出中最高值的位置与原始 logits 中最高值的位置相同。换句话说,我们可以直接对 logits 应用 `torch.argmax` 得到相同的结果。不过,我们保留了这个转换过程,以展示从 logits 到概率的完整过程,有助于理解模型如何生成最可能的下一个词,这种方式称为贪婪解码。
在下一章中,我们将在实现 GPT 训练代码的同时,引入一些新的采样技术,通过修改 softmax 输出,使模型在生成文本时不总是选择概率最高的词,这可以增加文本的多样性和创造性。
我们可以使用 `generate_text_simple` 函数逐步生成 token ID,每次生成一个 token ID 并将其附加到上下文中。其具体过程详见图 4.18(每次迭代生成 token ID 的步骤详见图 4.17)。
如图 4.18 所示,我们以迭代的方式逐步生成 token ID。例如,在第 1 轮迭代中,模型接收到“Hello , I am”对应的 token 作为输入,预测下一个 token(ID 为 257,对应“a”),并将其添加到输入序列中。这个过程不断重复,直到模型在第六轮迭代后生成完整的句子“Hello, I am a model ready to help.”。
接下来我们将实践 `generate_text_simple` 函数,并使用 ‘Hello, I am’ 作为模型的输入上下文,具体如图 4.18 所示。
首先,我们将输入上下文编码为 token ID:
```python
start_context = "Hello, I am"
encoded = tokenizer.encode(start_context)
print("encoded:", encoded)
encoded_tensor = torch.tensor(encoded).unsqueeze(0) #A
print("encoded_tensor.shape:", encoded_tensor.shape)
#A 添加批次维度
```
编码后的 token ID 如下:
```python
encoded: [15496, 11, 314, 716]
encoded_tensor.shape: torch.Size([1, 4])
```
接下来,将模型置于 `.eval()` 模式,禁用训练时使用的随机组件(如 dropout),然后在编码后的输入张量上使用 `generate_text_simple` 函数进行文本生成:
```python
model.eval() #A
out = generate_text_simple(
model=model,
idx=encoded_tensor,
max_new_tokens=6,
context_size=GPT_CONFIG_124M["context_length"]
)
print("Output:", out)
print("Output length:", len(out[0]))
#A 禁用 dropout,因为当前不是在训练模型
```
输出 token ID 如下:
```python
Output: tensor([[15496, 11, 314, 716, 27018, 24086, 47843, 30961, 42348, 7267]])
Output length: 10
```
接着,使用分词器的 `.decode` 方法可以将 ID 转回文本:
```python
decoded_text = tokenizer.decode(out.squeeze(0).tolist())
print(decoded_text)
```
模型输出如下:
```python
Hello, I am Featureiman Byeswickattribute argue
```
从以上的输出可以看到,模型生成的是一些毫无意义的内容,完全不像图 4.18 中的连贯文本。这是为什么呢?原因在于模型还没有经过训练。到目前为止,我们只是实现了 GPT 架构,并用随机权重初始化了模型实例。
模型训练本身就是一个庞大的主题,我们将在下一章详细讨论。
> [!NOTE]
>
> **练习 4.3:使用独立的 Dropout 参数**
>
> 在本章开头,我们在 `GPT_CONFIG_124M` 字典中定义了一个全局的 `"drop_rate"` 设置,用于统一设置整个 GPTModel 架构中各处的 dropout 率。请修改代码,为模型架构中各个不同的 dropout 层指定独立的 dropout 值。(提示:我们在三个不同的地方使用了dropout层:嵌入层、快捷连接层和多头注意力模块)
## 4.8 本章摘要
+ 层归一化通过确保每一层的输出具有一致的均值和方差,从而稳定训练过程。
+ 在大语言模型(LLM)中,快捷连接可以通过将某一层的输出直接传递给更深层来跳过一个或多个层,有助于缓解深度神经网络训练中的梯度消失问题。
+ Transformer 模块是 GPT 模型的核心结构,结合了掩码多头注意力模块和使用 GELU 激活函数的全连接前馈网络。
+ GPT 模型是由许多重复的 Transformer 模块组成的大语言模型,参数量高达数百万到数十亿。
+ GPT 模型有不同的规模,例如 1.24 亿、3.45 亿、7.62 亿和 15.42 亿参数。这些不同规模的模型可以用同一个 GPTModel 类来实现。
+ 类似 GPT 的大语言模型通过逐个预测 token,根据给定的输入上下文,将输出张量解码为可读文本,从而实现文本生成能力。
+ 未经训练的 GPT 模型生成的文本往往语义不连贯,这突显了训练对于生成连贯文本的重要性,这也将是后续章节的讨论重点。
## /cn-Book/5.å¨æÂ æ Âè®°æÂ°æÂ®éÂÂä¸Âè¿Âè¡Âé¢Âè®Âç»Â.md
本章涵盖以下内容:
+ **计算训练集和验证集的损失,以评估训练过程中大型语言模型生成文本的质量**
+ **实现训练函数并预训练大语言模型**
+ **保存和加载模型权重以便继续训练大语言模型**
+ **从OpenAI加载预训练权重**
-----
- [5.1 生成式文本模型的评估](#51-生成式文本模型的评估)
- [5.1.1 使用 GPT 生成文本](#511-使用-gpt-生成文本)
- [5.1.2 文本生成损失的计算](#512-文本生成损失的计算)
- [5.1.3 计算训练集和验证集的损失](#513-计算训练集和验证集的损失)
- [5.2 训练 LLM](#52-训练-llm)
- [5.3 通过解码策略控制生成结果的随机性](#53-通过解码策略控制生成结果的随机性)
- [5.3.1 Temperature scaling](#531-temperature-scaling)
- [5.3.2 Top-k 采样](#532-top-k-采样)
- [5.3.3 对文本生成函数进行调整](#533-对文本生成函数进行调整)
- [5.4 在 PyTorch 中加载和保存模型权重](#54-在-pytorch-中加载和保存模型权重)
- [5.5 从 OpenAI 加载预训练权重](#55-从-openai-加载预训练权重)
- [5.6 本章摘要](#56-本章摘要)
-----
在之前的章节中,我们实现了数据采样、注意力机制,并编写了 LLM 的架构。本章的核心是实现训练函数并对 LLM 进行预训练,详见图 5.1。
如图5.1所示,我们将继续学习基本的模型评估技术,以衡量生成文本的质量,这对于在训练过程中优化 LLM 是非常必要的。此外,我们将讨论如何加载预训练权重,以便为接下来的微调提供坚实的基础。
> [!NOTE]
>
> **权重参数**
>
> 在大语言模型(LLM)和其他深度学习模型中,权重指的是可以通过训练过程调整的参数,通常也被称为权重参数或直接称为参数。在 PyTorch 等框架中,这些权重通常存储在各层(如线性层)中,举例来说,我们在第 3 章实现的多头注意力模块和第 4 章实现的GPT模型中就使用了线性层。在初始化一个层(例如,`new_layer = torch.nn.Linear(...)`)后,我们可以通过`.weight`属性访问其权重,例如`new_layer.weight`。此外,出于便利性,PyTorch还允许通过`model.parameters()`方法直接访问模型的所有可训练参数,包括权重和偏置,我们将在后续实现模型训练时使用该方法。
## 5.1 生成式文本模型的评估
本章开篇,我们将基于上一章的代码设置 LLM 进行文本生成,并讨论如何对生成文本质量进行评估的基本方法。而本章剩余部分的内容请参考图5.2。
如图 5.2 所示,接下来的小节我们首先简要回顾上一章末尾的文本生成过程,然后深入探讨文本评估及训练和验证损失的计算方法。
### 5.1.1 使用 GPT 生成文本
在本节中,我们会先通过对 LLM 的设置简要回顾一下第四章中实现的文本生成过程。在开始这项工作之前,我们首先使用第 4 章中的 GPTModel 类和 GPT_CONFIG_124M 配置字典初始化 GPT 模型,以便在后续章节对其进行评估和训练:
```python
import torch
from chapter04 import GPTModel
GPT_CONFIG_124M = {
"vocab_size": 50257,
"context_length": 256, #A
"emb_dim": 768,
"n_heads": 12,
"n_layers": 12,
"drop_rate": 0.1, #B
"qkv_bias": False
}
torch.manual_seed(123)
model = GPTModel(GPT_CONFIG_124M)
model.eval()
#A 我们将上下文长度从1024个token缩短到256个token
#B 将 dropout 设置为 0 是一种常见的做法
```
在之前定义的 GPT_CONFIG_124M 配置字典中,我们唯一的调整是将上下文长度(context_length)减少到 256 个 token。此项调整降低了模型训练的计算需求,使得可以在普通笔记本电脑上进行训练。
参数量为 1.24 亿的 GPT-2 模型最初被配置为可处理最多 1024 个 token。本章结束时,我们将更新上下文大小设置,并加载预训练权重,使模型能够支持 1024-token 的上下文长度。
我们通过前一章节中介绍的 generate_text_simple 函数来使用 GPTmodel 实例,同时引入了两个实用函数:text_to_token_ids 和token_ids_to_text。这些函数简化了文本与 token 表示之间的转换,本章中我们将多次使用这种技术。图 5.3 可以帮助我们更清楚地理解这一过程。
图 5.3 展示了使用 GPT 模型生成文本的三个主要步骤。首先,分词器将输入文本转换为一系列 token ID(在第 2 章中已有讨论)。然后,模型接收这些 token ID 并生成对应的 logits(即词汇表中每个 token 的概率分布,具体见第 4 章)。最后,将 logits 转换回 token ID,分词器将其解码为可读的文本,完成从文本输入到文本输出的循环。
我们通过代码来实现上述过程:
```python
# Listing 5.1 Utility functions for text to token ID conversion
import tiktoken
from chapter04 import generate_text_simple
def text_to_token_ids(text, tokenizer):
encoded = tokenizer.encode(text, allowed_special={'<|endoftext|>'})
encoded_tensor = torch.tensor(encoded).unsqueeze(0) # add batch dimension
return encoded_tensor
def token_ids_to_text(token_ids, tokenizer):
flat = token_ids.squeeze(0) # remove batch dimension
return tokenizer.decode(flat.tolist())
start_context = "Every effort moves you"
tokenizer = tiktoken.get_encoding("gpt2")
token_ids = generate_text_simple(
model=model,
idx=text_to_token_ids(start_context, tokenizer),
max_new_tokens=10,
context_size=GPT_CONFIG_124M["context_length"]
)
print("Output text:\n", token_ids_to_text(token_ids, tokenizer))
```
执行代码,模型生成的文本如下:
```python
Output text:
Every effort moves you rentingetic wasnم refres RexMeCHicular stren
```
从输出可以看出,模型尚未生成连贯的文本,因为它还没有经过训练。为了定义文本的‘连贯性’或‘高质量’,我们需要实现一种数值方法来评估生成的内容。这一方法将帮助我们在训练过程中监督并提升模型的性能。
接下来将介绍如何计算生成内容的损失度量,该损失值会作为训练进展和效果的指示器。此外,在后续关于微调 LLM 的章节中,我们将探讨更多评估模型质量的方法。
### 5.1.2 文本生成损失的计算
本节将探讨如何通过计算‘文本生成损失’来数值化评估训练过程中生成的文本质量。在通过一个实际示例逐步讲解这一主题之前,先简要回顾第 2 章的数据加载方式以及第 4 章的`generate_text_simple`函数如何生成文本。
图 5.4 展示了从输入文本到 LLM 生成文本的整体流程,该流程通过五个步骤实现。
图 5.4 展示了第 4 章中`generate_text_simple`函数内部的本生成过程。在后续章节中计算生成文本的质量损失之前,我们需要先执行这些初始步骤。
为了便于在一页中展示图像,我们图中的示例仅使用了包含 7 个 token 的小型词汇表。然而,GPTModel 实际上使用了包含 50,257 个 token 的大型词汇表,因此在接下来的代码中,token ID 的范围为 0 到 50,256,而不是图示中的 0 到 6。
此外,图 5.4 为了简洁仅展示了一个文本示例 'every effort moves'。在接下来的代码示例中,我们将实现图 5.4 中的步骤,并使用两个输入示例 'every effort moves' 和 'I really like' 作为 GPT 模型的输入。
考虑两个输入样本,它们已经被转换为 token ID,对应图 5.4 中的步骤 1:
```python
inputs = torch.tensor([[16833, 3626, 6100], # ["every effort moves",
[40, 1107, 588]]) # "I really like"]
# Matching these inputs, the `targets` contain the token IDs we aim for the model to produce:
targets = torch.tensor([[3626, 6100, 345 ], # [" effort moves you",
[107, 588, 11311]]) # " really like chocolate"]
```
需要注意的是,目标值中展示的是输入数据向前偏移了一个位置。我们在第 2 章实现数据加载器时已介绍过这一概念。这种偏移策略对于教会模型预测序列中的下一个 token 至关重要。
接着我们将两个输入示例(每个示例样本包含三个 token)输入模型以计算它们的 logit 向量,再应用 Softmax 函数将这些 logit 值转换为概率得分,这对应于图 5.4 中的步骤 2:
```python
with torch.no_grad(): #A
logits = model(inputs)
probas = torch.softmax(logits, dim=-1) # Probability of each token in vocabulary
print(probas.shape)
#A 禁用梯度跟踪,因为我们尚未进行训练
```
生成的概率得分张量(probas)的维度如下:
```python
torch.Size([2, 3, 50257])
```
第一个数字 2 表示输入中的两个样本(行),即批次大小。第二个数字 3 表示每个样本包含的 token 数量。最后一个数字表示嵌入维度的大小,通常由词汇表大小决定,前面章节已讨论。
通过 softmax 函数将 logits 转换为概率后,第 4 章的 generate_text_simple 函数会将概率得分进一步转换回文本,这一过程在图 5.4 的步骤 3 到步骤 5 中进行了展示。
接下来,通过对概率得分应用 `argmax` 函数,可以得到对应的 token ID(实现步骤 3 和 步骤 4):
```python
token_ids = torch.argmax(probas, dim=-1, keepdim=True)
print("Token IDs:\n", token_ids)
```
假设我们有 2 个输入样本,每个样本包含 3 个 token。在对概率得分应用 argmax 函数后(对应图 5.4 的第 3 步),会得到 2 组输出,每组包含 3 个预测的 token ID:
```python
Token IDs:
tensor([[[16657], # First batch
[ 339],
[42826]],
[[49906], # Second batch
[29669],
[41751]]])
```
最后,步骤 5 将 token ID 转换回文本:
```python
print(f"Targets batch 1: {token_ids_to_text(targets[0], tokenizer)}")
print(f"Outputs batch 1: {token_ids_to_text(token_ids[0].flatten(), tokenizer)}")
#When we decode these tokens, we find that these output tokens are quite different from the target tokens we want the model to generate:
Targets batch 1: effort moves you
Outputs batch 1: Armed heNetflix
```
可以看到,模型生成的文本与目标文本不同,因为它尚未经过训练。接下来,我们将通过‘损失’来数值化评估模型生成文本的质量(详见图 5.5)。这不仅有助于衡量生成文本的质量,还为实现训练函数提供了基础,训练函数主要通过更新模型权重来改善生成文本的质量。
文本评估过程的一部分(如图 5.5 所示)是衡量生成的 token 与正确预测目标之间的差距。本章后面实现的训练函数将利用这些信息来调整模型权重,使生成的文本更接近(或理想情况下完全匹配)目标文本。
换句话说,模型训练的目标是提高正确目标 token ID 所在位置的 softmax 概率,如图 5.6 所示。接下来的部分中,我们还会将该 softmax 概率作为评价指标,用于对模型生成的输出进行数值化评估:正确位置上的概率越高,模型效果越好。
请注意,图 5.6 使用了一个包含 7 个 token 的简化词汇表,以便所有内容可以在一张图中展示。这意味着 softmax 的初始随机值会在 1/7 左右(约 0.14)。
然而,我们为 GPT-2 模型使用的词汇表包含 50,257 个 token,因此每个 token 的初始概率大约只有 0.00002(即 1/50,257)。
对于这两个输入文本,我们可以通过以下代码打印与目标 token 对应的初始 softmax 概率得分:
```python
text_idx = 0
target_probas_1 = probas[text_idx, [0, 1, 2], targets[text_idx]]
print("Text 1:", target_probas_1)
text_idx = 1
target_probas_2 = probas[text_idx, [0, 1, 2], targets[text_idx]]
print("Text 2:", target_probas_2)
```
每个批次中 3 个目标 token ID 的概率如下:
```python
Text 1: tensor([7.4541e-05, 3.1061e-05, 1.1563e-05])
Text 2: tensor([1.0337e-05, 5.6776e-05, 4.7559e-06])
```
训练 LLM 的目标就是最大化这些概率值,使其尽量接近 1。这样可以确保 LLM 始终选择目标 token —— 即句中的下一个词,作为生成的下一个 token。
> [!NOTE]
>
> **反向传播**
>
> 如何最大化目标 token 的 softmax 概率值?整体思路是通过更新模型权重,使模型在生成目标 token 时输出更高的概率值。权重更新通过一种称为反向传播的过程来实现,这是一种训练深度神经网络的标准技术(关于反向传播和模型训练的更多细节可见附录 A 的 A.3 至 A.7 节)。
>
> 反向传播需要一个损失函数,该函数用于计算模型预测输出与实际目标输出之间的差异(此处指与目标 token ID 对应的概率)。这个损失函数用于衡量模型预测与目标值的偏差程度。
在本节剩余内容中,我们将针对`target_probas_1`和`target_probas_2`的概率得分计算损失。图 5.7 展示了主要步骤。
由于我们已经完成了图 5.7 中列出的步骤 1-3,得到了 `target_probas_1` 和 `target_probas_2`,现在进行第 4 步,对这些概率得分取对数:
```python
log_probas = torch.log(torch.cat((target_probas_1, target_probas_2)))
print(log_probas)
```
计算结果如下:
```python
tensor([ -9.5042, -10.3796, -11.3677, -11.4798, -9.7764, -12.2561])
```
在数学优化中,处理概率得分的对数比直接处理概率得分更为简便。该主题超出本书的讨论范围,但我在一个讲座中对此进行了详细讲解,链接位于附录 B 的参考部分。
> [!TIP]
>
> **个人思考:** 在继续接下来的计算之前,我们首先来探讨一下,对数在损失函数的应用中到底有什么作用。
>
> 1. **为什么要用概率的对数**
>
> 在 LLM 中,概率得分通常是小于1的数(例如0.1、0.05等),直接用这些数进行计算和优化可能会面临一些问题。比如,如果多个概率相乘,结果会变得非常小,甚至接近0。这种情况称为“数值下溢”(Numerical Underflow),可能导致计算不稳定。
>
> 假设我们有三个概率值,分别为0.2、0.1和0.05。如果我们计算这些值的乘积,结果是:
>
> $$0.2×0.1×0.05=0.001$$
>
> 这个值非常小,尤其在深度学习或概率模型中,我们通常会有成千上万个概率需要相乘,这样会导致最终的乘积接近0甚至为0,造成数值计算的不稳定性。
>
> 如果我们对这些概率值取对数,然后相加,而不是直接相乘,我们可以避免这个问题。例如,对这三个值取自然对数(logarithm)后再相加:
>
> $$ln(0.2)+ln(0.1)+ln(0.05)≈−1.6094+(−2.3026)+(−2.9957)=−6.9077$$
>
> 虽然这个和也是负数,但它不会像直接相乘的结果那样接近于0,避免了数值下溢的问题。**对数的累加性质**允许我们将原本的累乘操作转换为累加,使得计算更加稳定和高效。
>
>
>
> 2. 对数概率在损失函数中的作用**
>
> GPT模型训练的目标是最大化正确目标 token 的概率,通常,我们会使用交叉熵损失来衡量模型预测与实际目标之间的差异。对于一个目标 token 序列 y=(y1,y2,…,yn),GPT会生成一个对应的预测概率分布 P(y∣x),其中 x 是模型的输入。
>
> **交叉熵损失的公式:**
>
> 在计算交叉熵损失时,我们希望最大化模型分配给每个正确目标token的概率。交叉熵损失的数学公式为:
>
> $$\text { Loss }=-\sum_{t=1}^{T} \ln P\left(y_{t} \mid x, \theta\right)$$
>
> 其中:
>
> + T 是序列长度
> + yt 是在位置 ttt 上的目标token
> + P(yt∣x,θ) 是模型在参数 θ 下对目标token yt 的条件概率
>
> 在公式中,对每个token的概率 P(yt∣x,θ) 取对数,将乘积形式的联合概率转换为求和形式,有助于避免数值下溢,同时简化优化过程。
接下来,通过计算平均值将这些对数概率合并为一个评分(参见图 5.7 的第 5 步):
```python
avg_log_probas = torch.mean(log_probas)
print(avg_log_probas)
```
由此生成的平均对数概率评分如下:
```python
tensor(-10.7940)
```
训练的目标就是通过更新模型权重,使平均对数概率尽可能接近 0(将在 5.2 节中实现)。
然而,在深度学习中,常见做法并不是直接将平均对数概率推向 0,而是通过将负平均对数概率降低至 0 来实现。负平均对数概率就是平均对数概率乘以 -1,这与图 5.7 的第 6 步相对应:
```python
neg_avg_log_probas = avg_log_probas * -1
print(neg_avg_log_probas)
```
结算的结果为:`tensor(10.7940)`。
这种将负值 -10.7940 转化为正值 10.7940 的操作在深度学习中称为交叉熵损失。
在这里,PyTorch 非常实用,因为它内置的 cross_entropy 函数已经自动处理了图 5.7 中的 6 个步骤。
> [!NOTE]
>
> **交叉熵损失**
>
> 本质上,交叉熵损失是在机器学习和深度学习中一种常用的度量方法,用于衡量两个概率分布之间的差异——通常是标签的真实分布(此处为数据集中的 token)和模型的预测分布(例如,LLM 生成的 token 概率)。
>
> 在机器学习,特别是 PyTorch 等框架中,cross_entropy 函数用于计算离散输出的损失,与模型生成的 token 概率下的目标 token 的负平均对数概率类似。因此,cross entropy 和负平均对数概率这两个术语在计算上有关联,实践中经常互换使用。
在应用交叉熵函数之前,我们先简要回顾一下 logits 和目标张量的形状:
```python
print("Logits shape:", logits.shape)
print("Targets shape:", targets.shape)
# The resulting shapes are as follows:
Logits shape: torch.Size([2, 3, 50257])
Targets shape: torch.Size([2, 3])
```
可以看到,logits 是个三维张量(批量大小、token 数量和词汇表大小)。而 targets 是个二维张量(批量大小和 token 数量)。
在 PyTorch 中使用交叉熵损失函数时,我们需要将这些张量展平,以便在批量维度上进行合并:
```python
logits_flat = logits.flatten(0, 1)
targets_flat = targets.flatten()
print("Flattened logits:", logits_flat.shape)
print("Flattened targets:", targets_flat.shape)
```
得到的张量维度如下:
```python
Flattened logits: torch.Size([6, 50257])
Flattened targets: torch.Size([6])
```
请记住,targets 是希望 LLM 生成的目标 token ID,而 logits 包含了在进入 softmax 函数之前的模型原始输出。
我们之前的实现是先应用 Softmax 函数,再选择目标 token ID 对应的概率分数,计算负的平均对数概率。而在 PyTorch 中,`cross_entropy` 函数能够自动完成所有这些步骤:
```python
loss = torch.nn.functional.cross_entropy(logits_flat, targets_flat)
print(loss)
```
计算得到的损失值与之前手动执行图 5.7 中各个步骤时获得的结果相同:
```python
tensor(10.7940)
```
> [!NOTE]
>
> **Perplexity**
>
> `Perplexity` 是一种经常与交叉熵损失一起使用的指标,用于评估语言建模等任务中的模型表现。它能够以更具可解释性的方式,帮助理解模型在预测下一个 token 时的不确定性。
>
> `Perplexity` 常用于衡量模型预测的概率分布与数据集中词的实际分布的接近程度。类似于损失函数,`Perplexity`的值越低,表示模型预测越接近真实分布。
>
> `Perplexity`可通过 `perplexity = torch.exp(loss)` 计算,对先前计算的损失值应用此公式将返回 `tensor(48725.8203)`。
>
> `Perplexity`通常比原始损失值更具可解释性,因为它表示了模型在每一步生成中,对有效词汇量的不确定程度。在这个例子中,`Perplexity`可以理解为模型在词汇表中的 47,678 个单词或 token 中,不确定该选择哪个作为下一个生成的 token。
在本节中,我们对两个小文本输入进行了损失计算,以便更直观地说明损失函数的计算过程。下一节将把损失计算应用于整个训练集和验证集。
### 5.1.3 计算训练集和验证集的损失
在本节中,我们首先准备训练和验证数据集,以用于后续 LLM 的训练。接着,我们计算训练集和验证集的交叉熵(如图 5.8 所示),这是模型训练过程中的重要组成部分。
为了计算训练集和验证集上的损失(如图 5.8 所示),我们使用了一个非常小的文本数据集,即伊迪丝·华顿的短篇小说《判决》,我们在第 2 章中已对此文本进行过处理。选择公共领域的文本可以避免任何关于使用权的担忧。此外,我们选择小数据集的原因在于,它允许代码示例在普通笔记本电脑上运行,即使没有高端 GPU 也能在几分钟内完成,这对于教学尤为有利。
感兴趣的读者可以使用本书的配套代码,准备一个包含超过 60,000 本 Project Gutenberg 公有领域书籍的大规模数据集,并在此数据集上训练 LLM(详情请见附录 D)。
> [!NOTE]
>
> **预训练 LLM 的成本**
>
> 为了更好地理解项目的规模,以一个相对受欢迎的开源 LLM - 70 亿参数的 Llama 2 模型的训练为例。该模型的训练在昂贵的 A100 GPU 上共耗费了 184,320 个小时,处理了 2 万亿个 token。在撰写本文时,AWS 上 8 张 A100 卡的云服务器每小时费用约为 30 美元。粗略估算,训练这样一个 LLM 的总成本约为 69 万美元(计算方法为 184,320 小时除以 8,再乘以 30 美元)。
以下代码用于加载我们在第 2 章中使用的《判决》短篇小说:
```python
file_path = "the-verdict.txt"
with open(file_path, "r", encoding="utf-8") as file:
text_data = file.read()
```
加载数据集后,我们可以查看其中的字符数和 token 数:
```python
total_characters = len(text_data)
total_tokens = len(tokenizer.encode(text_data))
print("Characters:", total_characters)
print("Tokens:", total_tokens)
```
输出如下:
```python
Characters: 20479
Tokens: 5145
```
仅有 5,145 个 token,看起来似乎不足以训练一个 LLM,但正如前面提到的,这仅用于教学演示,因此我们可以将代码的运行时间控制在几分钟,而不是几周。此外,在本章最后,我们将把 OpenAI 的预训练权重加载到我们的 GPTModel 代码中。
接下来,我们将数据集划分为训练集和验证集,并使用第二章的数据加载器为 LLM 训练准备需输入的批量数据。图 5.9 展示了该过程。
出于可视化的需要,图 5.9 将最大长度设置为 6。然而,在实际数据加载器中,我们会将最大长度设置为 LLM 支持的 256 个 token 的上下文长度,使得模型在训练时可以看到更长的文本。
> [!NOTE]
>
> **处理变长输入的训练**
>
> 在训练模型时,我们可以使用大小相似的数据块来保证训练过程的简便和高效。然而,在实践中,使用变长的输入进行训练往往有助于提升 LLM 的泛化能力,使其在应用时能够适应不同类型的输入。
为了实现图 5.9 中的数据划分与加载,我们首先定义一个 `train_ratio`,用于将 90% 的数据用于训练,剩余 10% 用于在训练期间进行模型评估:
```python
train_ratio = 0.90
split_idx = int(train_ratio * len(text_data))
train_data = text_data[:split_idx]
val_data = text_data[split_idx:]
```
现在可以使用 train_data 和 val_data 子集,复用第 2 章中的 create_dataloader_v1 代码来创建相应的数据加载器:
```python
from chapter02 import create_dataloader_v1
torch.manual_seed(123)
train_loader = create_dataloader_v1(
train_data,
batch_size=2,
max_length=GPT_CONFIG_124M["context_length"],
stride=GPT_CONFIG_124M["context_length"],
drop_last=True,
shuffle=True,
num_workers=0
)
val_loader = create_dataloader_v1(
val_data,
batch_size=2,
max_length=GPT_CONFIG_124M["context_length"],
stride=GPT_CONFIG_124M["context_length"],
drop_last=False,
shuffle=False,
num_workers=0
)
```
在前面的代码示例中,由于数据集较小,我们使用了较小的批量以降低计算资源的消耗。实际训练 LLM 时,批量大小达到 1,024 或更高并不少见。
为了确认数据加载器是否正确创建,可以通过遍历这些数据加载器来检查:
```python
print("Train loader:")
for x, y in train_loader:
print(x.shape, y.shape)
print("\nValidation loader:")
for x, y in val_loader:
print(x.shape, y.shape)
```
执行代码,可以看到以下输出:
```python
Train loader:
torch.Size([2, 256]) torch.Size([2, 256])
torch.Size([2, 256]) torch.Size([2, 256])
torch.Size([2, 256]) torch.Size([2, 256])
torch.Size([2, 256]) torch.Size([2, 256])
torch.Size([2, 256]) torch.Size([2, 256])
torch.Size([2, 256]) torch.Size([2, 256])
torch.Size([2, 256]) torch.Size([2, 256])
torch.Size([2, 256]) torch.Size([2, 256])
torch.Size([2, 256]) torch.Size([2, 256])
Validation loader:
torch.Size([2, 256]) torch.Size([2, 256])
```
可以看到,训练集中共有 9 个批次,每批包含 2 个样本,每个样本有 256 个 token。由于只分配了 10% 的数据用于验证,因此验证集中只有 1 个批次,包含 2 个样本。
和我们的预期一致,输入数据(x)和目标数据(y)的形状相同(即批次大小 × 每批的 token 数量),因为目标数据是将输入数据整体向后偏移一个位置得到的,正如第 2 章讨论的那样。
接下来我们实现一个工具函数,用于计算由训练和验证加载器返回的批量数据的交叉熵损失:
```python
def calc_loss_batch(input_batch, target_batch, model, device):
input_batch, target_batch = input_batch.to(device), target_batch.to(device) #A
logits = model(input_batch)
loss = torch.nn.functional.cross_entropy(
logits.flatten(0, 1), target_batch.flatten()
)
return loss
#A 将数据传输到指定设备(如 GPU),使数据能够在 GPU 上处理。
```
现在我们可以使用 `calc_loss_batch` 工具函数来实现 `calc_loss_loader` 函数,`calc_loss_loader` 将用于计算指定数据加载器中的指定数据批次的损失:
```python
# Listing 5.2 Function to compute the training and validation loss
def calc_loss_loader(data_loader, model, device, num_batches=None):
total_loss = 0.
if len(data_loader) == 0:
return float("nan")
elif num_batches is None:
num_batches = len(data_loader) #A
else:
num_batches = min(num_batches, len(data_loader)) #B
for i, (input_batch, target_batch) in enumerate(data_loader):
if i < num_batches:
loss = calc_loss_batch(input_batch, target_batch, model, device)
total_loss += loss.item() #C
else:
break
return total_loss / num_batches #D
#A 如果没有指定批次数,将自动遍历所有批次
#B 若批次数超过数据加载器的总批次数,则减少批次数使其与数据加载器的批次数相匹配
#C 每个批次的损失求和
#D 对所有批次的损失取平均值
```
默认情况下,`calc_loss_batch` 函数会遍历 `data loader` 中的所有批次数据,将每批次的损失累加到 `total_loss` 中,并计算所有批次的平均损失。作为替代方案,我们可以通过 `num_batches` 参数指定更少的批次数,以加快模型训练过程中的评估速度。
现在让我们看看如何将 `calc_loss_batch` 函数应用到训练集和验证集加载器中:
```python
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") #A
model.to(device)
with torch.no_grad(): #B
train_loss = calc_loss_loader(train_loader, model, device) #C
val_loss = calc_loss_loader(val_loader, model, device)
print("Training loss:", train_loss)
print("Validation loss:", val_loss)
#A 如果你的设备配备了支持 CUDA 的 GPU,LLM 将自动在 GPU 上进行训练,无需更改代码
#B 因为当前不在训练,为提高效率,关闭梯度跟踪
#C 通过 device 设置确保数据与 LLM 模型加载到同一设备上
```
损失值如下:
```python
Training loss: 10.98758347829183
Validation loss: 10.98110580444336
```
模型未经过训练,因此损失值较高。相比之下,如果模型学会按训练集和验证集中的真实顺序生成下一个 token,损失值就会接近 0。
现在我们已经有了评估生成文本质量的方法,接下来我们将训练 LLM 以减少损失,从而提升文本生成的效果,如图 5.10 所示。
如图 5.10 所示,下一节将重点讲解 LLM 的预训练过程。在模型训练完成后,将应用不同的文本生成策略,并保存和加载预训练模型的权重。
## 5.2 训练 LLM
在本节中,我们将实现 LLM(基于GPTModel)的预训练代码。我们重点采用一种简单的训练循环方式来保证代码简洁易读(如图 5.11 所示)。不过,有兴趣的读者可以在附录 D 中了解更多高级技术,包括学习率预热、余弦退火和梯度裁剪等,以进一步完善训练循环。
图 5.11 中的流程图展示了一个典型的 PyTorch 神经网络训练流程,我们用它来训练大语言模型(LLM)。流程概述了 8 个步骤,从迭代各个 epoch 开始,处理批次数据、重置和计算梯度、更新权重,最后进行监控步骤如打印损失和生成文本样本。如果你对使用 PyTorch 如何训练深度神经网络不太熟悉,可以参考附录 A 中的 A.5 至 A.8 节。
我们可以通过以下`train_model_simple`函数来实现这一训练流程:
```python
# Listing 5.3 The main function for pretraining LLMs
def train_model_simple(model, train_loader, val_loader, optimizer, device, num_epochs,
eval_freq, eval_iter, start_context, tokenizer):
train_losses, val_losses, track_tokens_seen = [], [], [] #A
tokens_seen, global_step = 0, -1
for epoch in range(num_epochs): #B
model.train()
for input_batch, target_batch in train_loader:
optimizer.zero_grad() #C
loss = calc_loss_batch(input_batch, target_batch, model, device)
loss.backward() #D
optimizer.step() #E
tokens_seen += input_batch.numel()
global_step += 1
if global_step % eval_freq == 0: #F
train_loss, val_loss = evaluate_model(
model, train_loader, val_loader, device, eval_iter)
train_losses.append(train_loss)
val_losses.append(val_loss)
track_tokens_seen.append(tokens_seen)
print(f"Ep {epoch+1} (Step {global_step:06d}): "
f"Train loss {train_loss:.3f}, Val loss {val_loss:.3f}")
generate_and_print_sample( #G
model, tokenizer, device, start_context
)
return train_losses, val_losses, track_tokens_seen
#A 初始化用于记录损失和已处理 token 数量的列表
#B 开始主训练循环
#C 重置上一批次的损失梯度
#D 计算损失梯度
#E 使用损失梯度更新模型权重
#F 可选的评估步骤
#G 每个 epoch 结束后打印示例文本
```
注意,我们刚刚创建的 `train_model_simple` 函数使用了两个尚未定义的函数:`evaluate_model` 和 `generate_and_print_sample`。
`evaluate_model` 函数对应图 5.11 中的步骤 7。该函数会在每次模型更新后打印训练集和验证集的损失,从而帮助我们评估训练是否改进了模型。
更具体地说,`evaluate_model` 函数会在训练集和验证集上计算损失,同时确保模型处于评估模式,并在计算损失时禁用梯度跟踪和 dropout:
```python
def evaluate_model(model, train_loader, val_loader, device, eval_iter):
model.eval() #A
with torch.no_grad(): #B
train_loss = calc_loss_loader(train_loader, model, device, num_batches=eval_iter)
val_loss = calc_loss_loader(val_loader, model, device, num_batches=eval_iter)
model.train()
return train_loss, val_loss
#A 评估阶段禁用 dropout,以确保结果稳定、可复现
#B 禁用梯度跟踪,减少计算开销
```
与 `evaluate_model` 类似,`generate_and_print_sample` 是一个工具函数,用于跟踪模型在训练过程中是否有改进。具体来说,`generate_and_print_sample` 函数接收一个文本片段(`start_context`)作为输入,将其转换为 token ID,并传递给 LLM,借助之前的 `generate_text_simple` 函数生成文本示例:
```python
def generate_and_print_sample(model, tokenizer, device, start_context):
model.eval()
context_size = model.pos_emb.weight.shape[0]
encoded = text_to_token_ids(start_context, tokenizer).to(device)
with torch.no_grad():
token_ids = generate_text_simple(
model=model, idx=encoded,
max_new_tokens=50, context_size=context_size
)
decoded_text = token_ids_to_text(token_ids, tokenizer)
print(decoded_text.replace("\n", " ")) # Compact print format
model.train()
```
`evaluate_model`函数通过数值来评估模型的训练进展,而`generate_and_print_sample text`函数则通过生成的实际文本示例,帮助我们在训练过程中判断模型的能力。
> [!NOTE]
>
> **ADAMW**
>
> Adam 优化器在深度神经网络训练中非常流行。然而在我们的训练循环中,我们选择了 AdamW 优化器。AdamW 是 Adam 的一种变体,通过改进权重衰减方式,帮助减少模型复杂度,并通过惩罚较大的权重来防止过拟合。这样的调整使得 AdamW 能更有效地实现正则化,并提升模型的泛化能力,因此被广泛应用于大语言模型的训练中。
让我们通过训练一个 GPTModel 实例来实际操作看看,训练 10 个 epoch,使用 AdamW 优化器和之前定义的`train_model_simple`函数:
```python
torch.manual_seed(123)
model = GPTModel(GPT_CONFIG_124M)
model.to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0004, weight_decay=0.1) #A
num_epochs = 10
train_losses, val_losses, tokens_seen = train_model_simple(
model, train_loader, val_loader, optimizer, device,
num_epochs=num_epochs, eval_freq=5, eval_iter=1,
start_context="Every effort moves you", tokenizer=tokenizer
)
#A .parameters() 方法返回模型的所有可训练权重参数
```
执行 `training_model_simple` 函数将开始训练过程,在 MacBook Air 或类似的笔记本电脑上完成约需 5 分钟。执行过程中打印的输出如下所示:
```python
Ep 1 (Step 000000): Train loss 9.781, Val loss 9.933
Ep 1 (Step 000005): Train loss 8.111, Val loss 8.339
Every effort moves you,,,,,,,,,,,,.
Ep 2 (Step 000010): Train loss 6.661, Val loss 7.048
Ep 2 (Step 000015): Train loss 5.961, Val loss 6.616
Every effort moves you, and, and, and, and, and, and, and, and, and, and, and, and, and,
and, and, and, and, and, and, and, and, and,, and, and,
[...] Results are truncated to save space #A
Ep 9 (Step 000080): Train loss 0.541, Val loss 6.393
Every effort moves you?" "Yes--quite insensible to the irony. She wanted him
vindicated--and by me!" He laughed again, and threw back the window-curtains, I had the
donkey. "There were days when I
Ep 10 (Step 000085): Train loss 0.391, Val loss 6.452
Every effort moves you know," was one of the axioms he laid down across the Sevres and
silver of an exquisitely appointed luncheon-table, when, on a later day, I had again run
over from Monte Carlo; and Mrs. Gis
#A 中间结果被省略以节省空间
```
根据训练过程中的输出结果,训练损失显著下降,从 9.558 降到 0.762,模型的语言能力大幅提升。在训练初期,模型仅能在起始上下文后添加逗号(如“Every effort moves you,,,,,,,,,,,,”)或重复单词“and”。而在训练结束时,模型能够生成符合语法的文本。
与训练集损失类似,我们可以看到验证集损失在开始时较高(9.856),随后在训练过程中下降。但它始终未能像训练集损失那样低,在第 10 个 epoch 后保持在 6.372。
在更详细地讨论验证集损失之前,我们先创建一个简单的图表,将训练集和验证集损失并排展示:
```python
import matplotlib.pyplot as plt
def plot_losses(epochs_seen, tokens_seen, train_losses, val_losses):
fig, ax1 = plt.subplots(figsize=(5, 3))
ax1.plot(epochs_seen, train_losses, label="Training loss")
ax1.plot(epochs_seen, val_losses, linestyle="-.", label="Validation loss")
ax1.set_xlabel("Epochs")
ax1.set_ylabel("Loss")
ax1.legend(loc="upper right")
ax2 = ax1.twiny() #A
ax2.plot(tokens_seen, train_losses, alpha=0) #B
ax2.set_xlabel("Tokens seen")
fig.tight_layout()
plt.show()
epochs_tensor = torch.linspace(0, num_epochs, len(train_losses))
plot_losses(epochs_tensor, tokens_seen, train_losses, val_losses)
#A 创建与 y 轴共用的第二个 x 轴
#B 用于对齐刻度的隐藏图形
```
生成的训练损失和验证损失图表如图 5.12 所示。
如图 5.12 所示,训练损失和验证损失在第一个 epoch 开始时都有所改善。然而,从第二个 epoch 之后,损失开始出现分歧。验证损失远高于训练损失,这表明模型在训练数据上出现了过拟合。我们可以通过搜索生成的文本片段(例如“The Verdict”文件中的片段:“quite insensible to the irony”)来确认模型逐词记住了训练数据。
这种记忆现象是预料之中的,因为我们使用了一个非常小的训练数据集,并且对模型进行了多轮训练。通常,我们会在更大的数据集上训练模型,并且只需训练一个 epoch 即可。
> [!TIP]
>
> **个人思考:** 让我们基于 LLM 的原理来探讨一下为什么在一个较小的数据集上进行多轮训练,容易产生过拟合的现象?
>
> 1. **模型容量与数据集大小的匹配问题**
> + 大语言模型具有极高的参数容量,通常包含数百万甚至数十亿个参数。如此巨大的参数空间可以高度灵活地适应数据,使得模型能够“记住”每个样本的具体特征
> + 当数据集很小时,模型没有足够的多样性去学习广泛的模式,而是倾向于学习每个数据点的细节。经过多轮训练,模型会逐渐“记住”小数据集中每个样本的特征,从而导致过拟合。
> 2. **多轮训练导致对数据集细节的过度学习**
> + 多轮训练意味着模型会反复接触相同的数据。这种重复使得模型逐渐适应数据集的特定模式,而不是学习一般化的规律。
> + 每次训练迭代都会使模型在数据集上拟合得更好,因此在训练数据上损失逐渐减小,但由于缺少新的数据,模型无法学习到通用模式,只会进一步记住训练样本的细节。
> 3. **数据集的多样性不足**
> + 小数据集通常不能代表广泛的语言特征和分布,缺乏多样性。模型在小数据集上多轮训练,基本上是在有限的样本范围内形成模式,导致它对特定的训练样本依赖性过强。
> + 这种缺乏多样性的训练会使模型偏向训练数据的分布,难以适应实际应用中广泛的输入数据。
> 4. **过拟合与模型泛化能力的矛盾**
> + 过拟合本质上是模型在训练数据上的表现优异,但在未见过的数据上表现较差。大语言模型的训练目标是提高其泛化能力,即能在更广泛的分布上生成有意义的文本。
> + 当数据集非常小且多轮训练时,模型会对数据的细节和噪声进行过度拟合,这会导致模型在测试数据或实际应用中表现不佳,因为它无法应对新的、不同分布的输入。
>
>
如前所述,感兴趣的读者可以尝试用 Project Gutenberg 中 60,000 本公共领域书籍来训练模型,这种情况下不会出现过拟合现象。详细信息见附录 B。
在接下来的部分(如图 5.13 所示),我们将探讨 LLM 使用的采样方法,这些方法可以减轻记忆效应,从而生成更具新意的文本。
如图 5.13 所示,下一节将介绍适用于 LLM 的文本生成策略,以减少训练数据的记忆倾向,提升 LLM 生成文本的原创性。之后我们还会讨论权重的加载与保存,以及从 OpenAI 的 GPT 模型加载预训练权重。
## 5.3 通过解码策略控制生成结果的随机性
本节将介绍文本生成策略(也称为解码策略),用于生成更具原创性的文本。首先,我们将简要回顾前一章中的`generate_text_simple`函数,该函数已在本章前面用于生成和打印样本。然后,我们会讲解两种改进方法:`temperature scaling`和 `top-k 采样`。
首先,我们将模型从 GPU 转移回 CPU,因为相对较小的模型在推理时不需要使用 GPU。另外,在训练结束后,我们会将模型切换到评估模式,以关闭 dropout 等随机组件:
```python
model.to("cpu")
model.eval()
```
接下来,将 GPTModel 的实例(model)传入 generate_text_simple 函数,该函数使用 LLM 一次生成一个 token:
```python
tokenizer = tiktoken.get_encoding("gpt2")
token_ids = generate_text_simple(
model=model,
idx=text_to_token_ids("Every effort moves you", tokenizer),
max_new_tokens=25,
context_size=GPT_CONFIG_124M["context_length"]
)
print("Output text:\n", token_ids_to_text(token_ids, tokenizer))
```
执行代码,会生成以下文本:
```python
Output text:
Every effort moves you know," was one of the axioms he laid down across the Sevres and
silver of an exquisitely appointed lun
```
如 5.1.2 节中所述,在生成过程中的每一步,都会选取词汇表中概率得分最高的 token 作为生成的 token。
接下来介绍两种控制生成文本随机性和多样性的方法:`temperature scaling`和`top-k sampling`。
### 5.3.1 Temperature scaling
本节将介绍`temperature scaling`,这是一种在生成下一个词时加入概率选择的技术。
之前,在 `generate_text_simple` 函数中,我们总是用 `torch.argmax` 选择概率最高的 token 作为下一个词,这也叫做贪心解码。为了生成更加多样化的文本,可以将 `argmax` 替换为一种从概率分布中进行采样的函数(这里,概率分布是指模型在每一步为每个词汇生成的概率得分)。
为了用具体的例子说明概率采样,我们将简要讨论下一词生成过程,并用一个非常小的词汇表来进行示例演示:
```python
vocab = {
"closer": 0,
"every": 1,
"effort": 2,
"forward": 3,
"inches": 4,
"moves": 5,
"pizza": 6,
"toward": 7,
"you": 8,
}
inverse_vocab = {v: k for k, v in vocab.items()}
```
接下来,假设给 LLM 一个初始上下文‘every effort moves you’,并生成下一个 token 的 logits 分数(如下所示):
```python
next_token_logits = torch.tensor(
[4.51, 0.89, -1.90, 6.75, 1.63, -1.62, -1.89, 6.28, 1.79]
)
```
接着在 `generate_text_simple` 函数中,通过 softmax 函数将 logits 转化为概率,并通过 argmax 函数得到生成的 token 的 ID,最后通过逆词汇表将其映射回文本(可以回顾上一章):
```python
probas = torch.softmax(next_token_logits, dim=0)
next_token_id = torch.argmax(probas).item()
print(inverse_vocab[next_token_id])
```
由于第四个位置的 logit 值最大,相应地,Softmax 归一化后的概率分数也在该位置上最大,因此生成的下一个词就是这个位置对应的词。
为了实现概率采样过程,现在可以用 PyTorch 中的 multinomial 函数代替 argmax:
```python
torch.manual_seed(123)
next_token_id = torch.multinomial(probas, num_samples=1).item()
print(inverse_vocab[next_token_id])
```
输出依然是“forward”,这和之前一样。这是为什么?
multinomial 函数根据每个 token 的概率得分来采样下一个 token。换句话说,“forward” 依然是最有可能的 token,因此大多数情况下会被 multinomial 选中,但并不是每次都选中。为了演示这一点,我们可以实现一个函数,重复采样 1000 次:
```python
def print_sampled_tokens(probas):
torch.manual_seed(123)
sample = [torch.multinomial(probas, num_samples=1).item() for i in range(1_000)]
sampled_ids = torch.bincount(torch.tensor(sample))
for i, freq in enumerate(sampled_ids):
print(f"{freq} x {inverse_vocab[i]}")
print_sampled_tokens(probas)
```
采样输出结果如下:
```python
73 x closer
0 x every
0 x effort
582 x forward
2 x inches
0 x moves
0 x pizza
343 x toward
```
从输出结果可以看出,单词‘forward’在生成过程中被采样的次数最多(在 1000 次生成中出现了 582 次),但‘closer’、‘inches’和‘toward’等其他词语也偶尔会被采样到。这意味着,如果在生成函数 generate_and_print_sample 中将 argmax 替换为 multinomial,模型有时会生成类似‘every effort moves you toward’、‘every effort moves you inches’和‘every effort moves you closer’这样的句子,而不是固定生成‘every effort moves you forward’。
我们可以通过一种称为`temperature scaling`的方法进一步控制分布和选择过程,所谓`temperature scaling`,其实就是将 logits 除以一个大于 0 的数:
```python
def softmax_with_temperature(logits, temperature):
scaled_logits = logits / temperature
return torch.softmax(scaled_logits, dim=0)
#Temperatures greater than 1 result in more uniformly distributed token probabilities, and Temperatures smaller than 1 will result in more confident (sharper or more peaky) distributions. Let's illustrate this by plotting the original probabilities alongside probabilities scaled with different temperature values:
temperatures = [1, 0.1, 5] #A
scaled_probas = [softmax_with_temperature(next_token_logits, T) for T in temperatures]
x = torch.arange(len(vocab))
bar_width = 0.15
fig, ax = plt.subplots(figsize=(5, 3))
for i, T in enumerate(temperatures):
rects = ax.bar(x + i * bar_width, scaled_probas[i],
bar_width, label=f'Temperature = {T}')
ax.set_ylabel('Probability')
ax.set_xticks(x)
ax.set_xticklabels(vocab.keys(), rotation=90)
ax.legend()
plt.tight_layout()
plt.show()
#A 原始、较低和较高置信度
```
图 5.14 展示了生成的图表:
当 temperature 取 1 时,logits 在传递给 softmax 函数之前会除以 1,计算概率得分。这意味着,temperature 为 1 时相当于不进行任何缩放。在这种情况下,模型将根据原始的 softmax 概率,通过 PyTorch 中的`multinomial`函数来选择 token。
如图 5.14 所示,当 temperature 设置为非常小的值(如 0.1)时,生成的分布会更加尖锐,因此`multinomial`函数几乎总是选择最可能的 token(这里是 ‘forward’),其行为接近 argmax 函数。相反,当 temperature 设置为 5 时,生成的分布更接近均匀分布,其他 token 被选中的频率更高。这种情况下,生成的文本多样性增加,但也更可能出现无意义的内容。例如,temperature 设置为 5 时,模型生成类似 ‘every effort moves you pizza’ 的文本概率大约为 4%。
> [!TIP]
>
> **个人思考:** 为什么 temperature 值非常小时,生成的概率分布会更加尖锐,越大时,概率分布会更加均匀,文中只是说了结论,没有说过程。
>
> **temperature** 参数被引入到 softmax 函数中,用于缩放 logits,从而控制输出的概率分布。当引入 temperature 后,softmax 函数的公式变为:
>
> $$ P\left(x_{i}\right)=\frac{\exp \left(\frac{z_{i}}{T}\right)}{\sum_{j} \exp \left(\frac{z_{j}}{T}\right)} $$
>
> 1. **当 T>1**
> 所有 logits 被除以 T,缩放后,差异变小。由于 exp 函数的敏感性较高,这意味着 logits 值的差异被“压平”,使得最优词的概率降低,而其他次优词的概率提高。输出的概率分布变得更加均匀,再结合multinomial函数,可以使生成结果更加多样化,但同时也降低了生成结果的确定性。
>
> 2. **当 T<1**
>
> logits 除以 T 后会被放大,差异变得更加显著。softmax 函数会使最高 logit 对应的词语的概率变得更高,其他词语的概率更低。这导致输出的概率分布更加集中,模型更倾向于选择概率最大的词,从而提高了生成结果的确定性。
> [!NOTE]
>
> **练习 5.1**
>
> 使用 `print_sampled_tokens` 函数,打印在图 5.14 所示 temperature 值下缩放的 Softmax 概率的采样频率。在每种情况下,单词“pizza”被采样的频率是多少?你能想到一种更快、更准确的方法来确定“pizza”被采样的频率吗?
### 5.3.2 Top-k 采样
在前一节中,我们实现了一种结合`temperature scaling`的概率采样方法来增加生成内容的多样性。我们发现,较高的 temperature 值会使下一词的概率分布更均匀,从而降低模型反复选择最可能词的概率,这样可以生成更多样化的内容,使生成过程探索那些概率较低但可能更有趣和创意的路径。不过,这种方法的一个缺点是,有时会导致生成语法不正确或完全不合逻辑的内容,比如 "every effort moves you pizza"。
在本节中,我们引入了另一种称为`top-k 采样`的概念,当与概率采样和`temperature scaling`结合使用时,可以提升文本生成效果。
在 top-k 采样中,我们可以将采样限制在最有可能的前 k 个 token 内,并通过将其他 token 的概率设为零,将它们排除在选择之外,如图 5.15 所示。
如图 5.15 所示,将所有未选中的 logits 替换为负无穷(-inf),这样在计算 Softmax 时,非 top-k 的 token 的概率为 0,剩下的概率之和为 1。(细心的读者可能记得,我们在第 3 章的因果注意力模块中使用过这种掩码技巧。)
接下来让我们通过代码实现 Figure 5.15 中描述的 top-k 过程,首先选出 logits 值最大的那些 token:
```python
top_k = 3
top_logits, top_pos = torch.topk(next_token_logits, top_k)
print("Top logits:", top_logits)
print("Top positions:", top_pos)
```
```python
Top logits: tensor([6.7500, 6.2800, 4.5100])
Top positions: tensor([3, 7, 0])
```
接下来,我们应用 PyTorch 的 where 函数,将非 top-3 的 token 的 logit 值设为负无穷大(-inf):
```python
new_logits = torch.where(
condition=next_token_logits < top_logits[-1], #A
input=torch.tensor(float('-inf')), #B
other=next_token_logits #C
)
print(new_logits)
#A 识别出小于 top 3 最小值的 logits
#B 将这些较小的 logits 赋值为负无穷大
#C 保留所有其他 token 的原始 logits
```
执行代码,得到以下用于预测下一个 token 的 logits (在 9 个 token 的词汇表中):
```python
tensor([4.5100, -inf, -inf, 6.7500, -inf, -inf, -inf, 6.2800, -inf])
```
最后,应用 softmax 函数将其转化为下一词的概率分布:
```python
topk_probas = torch.softmax(new_logits, dim=0)
print(topk_probas)
```
可以看到,通过 top-3 方法得到的结果是三个非零的概率得分:
```python
tensor([0.0615, 0.0000, 0.0000, 0.5775, 0.0000, 0.0000, 0.0000, 0.3610, 0.0000])
```
我们现在可以应用`temperature scaling` 和`multinomial`函数来进行概率采样,从这 3 个非零概率得分中选择下一个 token。在下一节中,我们将通过修改文本生成函数来实现此操作。
### 5.3.3 对文本生成函数进行调整
前两节介绍了两种增加 LLM 生成文本多样性的概念:`temperature scaling`和`top-k 采样`。本节中,我们将这两个概念整合并加入到之前用于生成文本的`generate_simple`函数中,从而创建一个新的`generate`函数:
```python
# Listing 5.4 A modified text generation function with more diversity
def generate(model, idx, max_new_tokens, context_size,
temperature=1.0, top_k=None, eos_id=None):
for _ in range(max_new_tokens): #A
idx_cond = idx[:, -context_size:]
with torch.no_grad():
logits = model(idx_cond)
logits = logits[:, -1, :]
if top_k is not None: #B
top_logits, _ = torch.topk(logits, top_k)
min_val = top_logits[:, -1]
logits = torch.where(
logits < min_val,
torch.tensor(float('-inf')).to(logits.device),
logits
)
if temperature > 0.0: #C
logits = logits / temperature
probs = torch.softmax(logits, dim=-1)
idx_next = torch.multinomial(probs, num_samples=1)
else: #D
idx_next = torch.argmax(logits, dim=-1, keepdim=True)
if idx_next == eos_id: #E
break
idx = torch.cat((idx, idx_next), dim=1)
return idx
#A For循环与之前相同:获取logits,仅关注最后的时间步
#B 在新步骤中,通过top-k采样过滤logits
#C 在新步骤中应用temperature scaling
#D 在未使用temperature scaling时,执行贪婪的下一个token选择
#E 如果遇到序列结束token且指定了eos_id,则提前停止生成
```
现在来看看这个新的`generate`函数的实际效果:
```python
torch.manual_seed(123)
token_ids = generate(
model=model,
idx=text_to_token_ids("Every effort moves you", tokenizer).to(device),
max_new_tokens=15,
context_size=GPT_CONFIG_124M["context_length"],
top_k=25,
temperature=1.4
)
print("Output text:\n", token_ids_to_text(token_ids, tokenizer))
```
生成的文本如下:
```python
Output text:
Every effort moves you stand to work on surprise, a one of us had gone with random-
```
正如我们所见,当前生成的文本与之前在 5.3 节开头用 `generate_simple` 函数生成的文本有很大不同(例如那句"Every effort moves you know," was one of the axioms he laid...!"),而后者是模型从训练集中记忆的一段话。
> [!NOTE]
>
> **练习 5.2**
>
> 尝试不同的 temperature 和 top-k 设置。根据你的观察,你能想到哪些应用场景适合较低的 temperature 和 top-k 设置吗?反之,哪些应用场景适合较高的 temperature 和 top-k 设置?(建议在本章末加载 OpenAI 的预训练权重后,再次进行此练习)
> [!NOTE]
>
> **练习 5.3**
>
> generate 函数有哪些不同的设置组合可以强制生成确定性行为,即禁用随机采样,使其输出始终一致,类似于 generate_simple 函数?
>
> 到目前为止,我们已介绍了如何预训练 LLM 并使用其生成文本。本章最后两节将讨论如何保存和加载训练好的 LLM,以及如何加载 OpenAI 的预训练权重。
## 5.4 在 PyTorch 中加载和保存模型权重
在本章中,我们讨论了如何数值化评估训练进度,以及从零开始预训练 LLM。尽管模型和数据集都相对较小,这次练习依然展示了预训练 LLM 的高昂成本。因此,能够保存 LLM 以避免每次在新会话中使用时都重新训练显得尤为重要。
如图 5.16 的章节概览所示,本节将介绍如何保存和加载预训练模型。然后,在接下来的部分中,我们将从 OpenAI 加载一个更强大的预训练 GPT 模型到我们的 GPTModel 实例中。
幸运的是,保存 PyTorch 模型相对简单。推荐的做法是保存模型的 `state_dict`(状态字典),这是一个字典,用于将模型的每一层映射到其对应的参数上,可以通过 `torch.save` 函数来实现,代码如下所示:
```python
torch.save(model.state_dict(), "model.pth")
```
在以上代码中,`model.pth`是用于保存 `state_dict` 的文件名。`.pth` 是 PyTorch 文件的惯用扩展名,但实际上也可以使用其他扩展名。
使用 `state_dict` 保存模型权重后,可以将权重加载到新的 GPTModel 模型实例中,具体操作如下:
```python
model = GPTModel(GPT_CONFIG_124M)
model.load_state_dict(torch.load("model.pth"))
model.eval()
```
正如第 4 章所讨论的,dropout 通过在训练过程中随机“丢弃”某些神经元,以防止模型过拟合。然而,在推理阶段,我们不希望随机丢弃网络中学到的任何信息。通过使用 `model.eval()`,模型会切换到推理阶段的评估模式,从而禁用 dropout 层。
如果计划稍后继续预训练模型(例如使用本章之前定义的 train_model_simple 函数),那么建议同时保存优化器状态。
AdamW 等自适应优化器会为每个模型参数存储额外信息。AdamW 使用历史数据动态调整每个模型参数的学习率。没有这些信息时,优化器会重置,模型可能无法有效学习,甚至无法正确收敛,进而失去生成连贯文本的能力。可以使用 `torch.save` 保存模型和优化器的状态,方法如下:
```python
torch.save({
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
},
"model_and_optimizer.pth"
)
```
接下来,我们可以按以下步骤恢复模型和优化器的状态:首先通过 `torch.load` 加载保存的数据,然后使用 `load_state_dict` 方法恢复状态:
```python
checkpoint = torch.load("model_and_optimizer.pth")
model = GPTModel(GPT_CONFIG_124M)
model.load_state_dict(checkpoint["model_state_dict"])
optimizer = torch.optim.AdamW(model.parameters(), lr=5e-4, weight_decay=0.1)
optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
model.train();
```
> [!NOTE]
>
> **练习 5.4**
>
> 保存权重后,在新的 Python 会话中加载模型和优化器,使用 train_model_simple 函数继续进行 1 个 epoch 的预训练。
## 5.5 从 OpenAI 加载预训练权重
之前,我们为了教学目的,使用有限的数据集(包含一本短篇小说集)训练了一个小型 GPT-2 模型,这样可以专注于讲解 LLM 的基本原理,而无需耗费大量时间和计算资源。
OpenAI 公开了 GPT-2 模型的权重,使我们不必投入数十万甚至数百万美元自行在大规模语料上重新训练模型。
在本节的余下部分,我们将把这些权重加载到 GPTModel 类中,并利用该模型进行文本生成。这里的权重是指存储在 PyTorch 的 Linear 和 Embedding 层的 `.weight`属性中的权重参数(在训练模型时,我们可以通过`model.parameters() `访问这些权重)。
在后续章节中,我们将复用这些预训练权重,对模型进行微调以用于文本分类任务,并遵循类似 ChatGPT 的指令。
请注意,OpenAI 最初使用 TensorFlow 来保存 GPT-2 的权重,因此在 Python 中加载这些权重需要安装 TensorFlow。另外,以下代码将使用进度条工具 tqdm 来跟踪下载进度,也需要提前安装。
请在终端中执行以下命令来安装所需的库:
```python
pip install tensorflow>=2.15.0 tqdm>=4.66
```
由于下载代码篇幅较长,主要是样板代码,因此本章不会浪费篇幅详细讨论。读者可以直接从本章的在线资源库下载 `gpt_download.py` 模块:
```python
import urllib.request
url = (
"https://raw.githubusercontent.com/rasbt/"
"LLMs-from-scratch/main/ch05/"
"01_main-chapter-code/gpt_download.py"
)
filename = url.split('/')[-1]
urllib.request.urlretrieve(url, filename)
```
接下来,在将此文件下载到本地目录后,建议读者简单查看文件内容,确保文件已正确保存并包含有效的 Python 代码。
我们现在可以从 `gpt_download.py` 文件中导入 `download_and_load_gpt2` 函数,从而将 GPT-2 的架构设置(settings)和权重参数(params)加载到 Python 会话中:
```
from gpt_download import download_and_load_gpt2
settings, params = download_and_load_gpt2(model_size="124M", models_dir="gpt2")
Executing the proceeding code downloads the following 7 files associated with the 124M
parameter GPT-2 model:
checkpoint: 100%|███████████████████████████| 77.0/77.0 [00:00<00:00, 63.9kiB/s]
encoder.json: 100%|█████████████████████████| 1.04M/1.04M [00:00<00:00, 2.20MiB/s]
hprams.json: 100%|██████████████████████████| 90.0/90.0 [00:00<00:00, 78.3kiB/s]
model.ckpt.data-00000-of-00001: 100%|███████| 498M/498M [01:09<00:00, 7.16MiB/s]
model.ckpt.index: 100%|█████████████████████| 5.21k/5.21k [00:00<00:00, 3.24MiB/s]
model.ckpt.meta: 100%|██████████████████████| 471k/471k [00:00<00:00, 2.46MiB/s]
vocab.bpe: 100%|████████████████████████████| 456k/456k [00:00<00:00, 1.70MiB/s]
```
> [!NOTE]
>
> **最新下载说明**
>
> 如果下载代码无法正常工作,可能是由于网络连接不稳定、服务器问题,或者 OpenAI 共享 GPT-2 模型权重的方式发生了变化。请访问本章节的在线代码库(https://github.com/rasbt/LLMs-from-scratch),以获取更新的操作说明。如有其他问题,也可在 Manning 论坛中提问。
代码执行完成后,查看 `settings` 和 `params` 的内容:
```python
print("Settings:", settings)
print("Parameter dictionary keys:", params.keys())
```
输出如下:
```python
Settings: {'n_vocab': 50257, 'n_ctx': 1024, 'n_embd': 768, 'n_head': 12, 'n_layer': 12}
Parameter dictionary keys: dict_keys(['blocks', 'b', 'g', 'wpe', 'wte'])
```
`settings` 和 `params` 都是 Python 字典。`settings` 字典存储了 LLM 的架构设置,与我们之前手动定义的 `GPT_CONFIG_124M` 设置类似;`params` 字典则包含实际的权重张量。注意,我们只打印了字典的键,因为打印整个权重内容会占用太多屏幕空间。不过,我们可以通过`print(params)` 打印整个字典,或使用特定的字典键选择对应张量进行查看,例如嵌入层的权重:
```python
print(params["wte"])
print("Token embedding weight tensor dimensions:", params["wte"].shape)
```
token 嵌入层的权重如下所示:
```python
[[-0.11010301 ... -0.1363697 0.01506208 0.04531523]
[ 0.04034033 ... 0.08605453 0.00253983 0.04318958]
[-0.12746179 ... 0.08991534 -0.12972379 -0.08785918]
...
[-0.04453601 ... 0.10435229 0.09783269 -0.06952604]
[ 0.1860082 ... -0.09625227 0.07847701 -0.02245961]
[ 0.05135201 ... 0.00704835 0.15519823 0.12067825]]
Token embedding weight tensor dimensions: (50257, 768)
```
我们通过 `download_and_load_gpt2(model_size="124M", ...)` 加载了最小的 GPT-2 模型权重。此外,OpenAI 还提供了更大规模模型的权重,包括 "355M"、"774M" 和 "1558M" 等。尽管模型规模不同,但其整体架构是相同的,如图 5.17 所示。
如图 5.17 所示,不同大小的 GPT-2 模型在总体架构上保持一致,但注意力头和 Transformer 模块等组件的重复次数以及嵌入维度大小有所不同。本章的剩余代码也会兼容这些更大的模型。
在将 GPT-2 模型的权重加载到 Python 后,我们还需要将这些权重从 `settings` 和 `params` 字典转移到 GPTModel 实例中:
```python
# First, we create a dictionary that lists the differences between the different GPT model sizes, as explained in Figure 5.17:
model_configs = {
"gpt2-small (124M)": {"emb_dim": 768, "n_layers": 12, "n_heads": 12},
"gpt2-medium (355M)": {"emb_dim": 1024, "n_layers": 24, "n_heads": 16},
"gpt2-large (774M)": {"emb_dim": 1280, "n_layers": 36, "n_heads": 20},
"gpt2-xl (1558M)": {"emb_dim": 1600, "n_layers": 48, "n_heads": 25},
}
# Suppose we are interested in loading the smallest model, "gpt2-small (124M)". We can use the corresponding settings from the model_configs table able to update our full-length GPT_CONFIG_124M we defined and used earlier throughout the chapter as follows:
model_name = "gpt2-small (124M)"
NEW_CONFIG = GPT_CONFIG_124M.copy()
NEW_CONFIG.update(model_configs[model_name])
```
细心的读者可能记得,我们之前设置的 token 长度是 256,但 OpenAI 的原始 GPT-2 模型使用的是 1,024 的 token 长度,因此我们需要相应地更新 NEW_CONFIG:
```python
NEW_CONFIG.update({"context_length": 1024})
```
此外,OpenAI 在多头注意力模块的线性层中使用了偏置向量,以实现查询(query)、键(key)和值(value)矩阵的计算。偏置向量在现代 LLM 中已不再常用,因为它们对提升模型性能没有帮助,因而不再必要。然而,由于我们使用的是预训练权重,为了保持一致性,仍需启用这些偏置向量:
```python
NEW_CONFIG.update({"qkv_bias": True})
# We can now use the updated NEW_CONFIG dictionary to initialize a new GPTModel instance:
gpt = GPTModel(NEW_CONFIG)
gpt.eval()
```
默认情况下,GPTModel 实例会使用随机权重进行预训练。而使用 OpenAI 的模型权重的最后一步是将 `params` 字典中加载的权重覆盖这些随机权重。
为此,我们首先来定义一个简单的`assign`工具函数,用于检查两个张量或数组(左侧和右侧)的维度或形状是否一致,并将右侧张量作为可训练的 PyTorch 参数返回:
```python
def assign(left, right):
if left.shape != right.shape:
raise ValueError(f"Shape mismatch. Left: {left.shape}, Right: {right.shape}")
return torch.nn.Parameter(torch.tensor(right))
```
接下来,我们定义一个名为 `load_weights_into_gpt` 的函数,用于将 `params` 字典中的权重加载到 GPT 模型实例中:
```python
# Listing 5.5 Loading OpenAI weights into our GPT model code
import numpy as np
def load_weights_into_gpt(gpt, params):
gpt.pos_emb.weight = assign(gpt.pos_emb.weight, params['wpe']) #A
gpt.tok_emb.weight = assign(gpt.tok_emb.weight, params['wte'])
for b in range(len(params["blocks"])): #B
q_w, k_w, v_w = np.split( #C
(params["blocks"][b]["attn"]["c_attn"])["w"], 3, axis=-1)
gpt.trf_blocks[b].att.W_query.weight = assign(
gpt.trf_blocks[b].att.W_query.weight, q_w.T)
gpt.trf_blocks[b].att.W_key.weight = assign(
gpt.trf_blocks[b].att.W_key.weight, k_w.T)
gpt.trf_blocks[b].att.W_value.weight = assign(
gpt.trf_blocks[b].att.W_value.weight, v_w.T)
q_b, k_b, v_b = np.split(
(params["blocks"][b]["attn"]["c_attn"])["b"], 3, axis=-1)
gpt.trf_blocks[b].att.W_query.bias = assign(
gpt.trf_blocks[b].att.W_query.bias, q_b)
gpt.trf_blocks[b].att.W_key.bias = assign(
gpt.trf_blocks[b].att.W_key.bias, k_b)
gpt.trf_blocks[b].att.W_value.bias = assign(
gpt.trf_blocks[b].att.W_value.bias, v_b)
gpt.trf_blocks[b].att.out_proj.weight = assign(
gpt.trf_blocks[b].att.out_proj.weight,
params["blocks"][b]["attn"]["c_proj"]["w"].T)
gpt.trf_blocks[b].att.out_proj.bias = assign(
gpt.trf_blocks[b].att.out_proj.bias,
params["blocks"][b]["attn"]["c_proj"]["b"])
gpt.trf_blocks[b].ff.layers[0].weight = assign(
gpt.trf_blocks[b].ff.layers[0].weight,
params["blocks"][b]["mlp"]["c_fc"]["w"].T)
gpt.trf_blocks[b].ff.layers[0].bias = assign(
gpt.trf_blocks[b].ff.layers[0].bias,
params["blocks"][b]["mlp"]["c_fc"]["b"])
gpt.trf_blocks[b].ff.layers[2].weight = assign(
gpt.trf_blocks[b].ff.layers[2].weight,
params["blocks"][b]["mlp"]["c_proj"]["w"].T)
gpt.trf_blocks[b].ff.layers[2].bias = assign(
gpt.trf_blocks[b].ff.layers[2].bias,
params["blocks"][b]["mlp"]["c_proj"]["b"])
gpt.trf_blocks[b].norm1.scale = assign(
gpt.trf_blocks[b].norm1.scale,
params["blocks"][b]["ln_1"]["g"])
gpt.trf_blocks[b].norm1.shift = assign(
gpt.trf_blocks[b].norm1.shift,
params["blocks"][b]["ln_1"]["b"])
gpt.trf_blocks[b].norm2.scale = assign(
gpt.trf_blocks[b].norm2.scale,
params["blocks"][b]["ln_2"]["g"])
gpt.trf_blocks[b].norm2.shift = assign(
gpt.trf_blocks[b].norm2.shift,
params["blocks"][b]["ln_2"]["b"])
gpt.final_norm.scale = assign(gpt.final_norm.scale, params["g"])
gpt.final_norm.shift = assign(gpt.final_norm.shift, params["b"])
gpt.out_head.weight = assign(gpt.out_head.weight, params["wte"]) #D
#A 将模型的位置嵌入和token 嵌入的权重设置为 params 中指定的值
#B 遍历模型中的每个 Transformer 模块
#C 使用 np.split 函数将注意力和偏置权重分为三等份,分别用于查询、键和值组件
#D OpenAI 的原始 GPT-2 模型在输出层中复用了 token 嵌入的权重,以减少参数总量,这一概念称为权重共享
```
在 `load_weights_into_gpt` 函数中,我们需要将 OpenAI 实现中的权重与自定义的 GPTModel 实现进行精确匹配。举个例子,OpenAI 将第一个 Transformer 模块的输出投影层权重存储在 `params["blocks"][0]["attn"]["c_proj"]["w"]` 中。而在我们的实现中,这个权重对应于 `gpt.trf_blocks[b].att.out_proj.weight`,其中 `gpt` 是一个 GPTModel 实例。
在开发 `load_weights_into_gpt` 函数时,由于 OpenAI 的命名规范和我们的略有不同,我们进行了大量的尝试。幸运的是,`assign` 函数会在张量维度不匹配时发出警告。此外,如果这个函数有错误,我们会发现生成的 GPT 模型无法生成连贯的文本,从而识别出问题。
我们暂时不在实际操作中尝试 `load_weights_into_gpt`,而是直接将 OpenAI 模型的权重加载到我们自己的 `GPTModel` 实例 `gpt` 中:
```python
load_weights_into_gpt(gpt, params)
gpt.to(device)
```
如果模型加载成功,就可以使用之前的 `generate` 函数生成新文本:
```python
torch.manual_seed(123)
token_ids = generate(
model=gpt,
idx=text_to_token_ids("Every effort moves you", tokenizer),
max_new_tokens=25,
context_size=NEW_CONFIG["context_length"],
top_k=50,
temperature=1.5
)
print("Output text:\n", token_ids_to_text(token_ids, tokenizer))
```
生成的文本如下:
```python
Output text:
Every effort moves you toward finding an ideal new way to practice something!
What makes us want to be on top of that?
```
我们可以确认模型权重已正确加载,因为模型能够生成连贯的文本;在这个过程中,哪怕一个小错误都会导致模型生成失败。
在接下来的章节中,我们将进一步使用该预训练模型,并对其进行微调,使其能够进行文本分类和指令执行。
> [!NOTE]
>
> **练习 5.5**
>
> 使用 OpenAI 预训练权重的 GPT 模型在‘The Verdict’数据集上计算训练集和验证集的损失。
> [!NOTE]
>
> **练习 5.6**
>
> 建议读者尝试不同规模的 GPT-2 模型,例如最大规模的 1558M 参数模型,并与本章加载的 124M 模型的生成效果进行比较。
## 5.6 本章摘要
+ 大语言模型在生成文本时,逐个生成 token。
+ 默认情况下,模型通过将输出转换为概率分数,并选择其中概率最高的 token 来生成下一个 token,这种方式称为“贪心解码”。
+ 通过概率采样和`temperature scaling`,可以影响生成文本的多样性和连贯性。
+ 训练集和验证集的损失可以用来评估 LLM 在训练过程中生成文本的质量。
+ 预训练 LLM 的过程就是通过调整模型权重来最小化训练损失。
+ LLM 的训练循环是深度学习中的标准流程,通常使用交叉熵损失和 AdamW 优化器。
+ 在大规模文本数据集上预训练 LLM 非常耗费时间和资源,因此可以加载 OpenAI 提供的开源预训练权重,作为自行预训练模型的替代方案。
## /cn-Book/6.ç¨äºÂÃ¥ÂÂ类任å¡çÂÂå¾®è°Â.md
本章涵盖以下内容:
+ **介绍不同的LLM微调方法**
+ **准备用于文本分类任务的数据集**
+ **调整预训练的 LLM 以便微调**
+ **微调 LLM 以识别垃圾短信**
+ **评估微调后的 LLM 分类器的准确性**
+ **使用微调后的 LLM 对新数据进行分类**
-----
- [6.1 不同类型的微调](#61-不同类型的微调)
- [6.2 准备数据集](#62-准备数据集)
- [6.3 创建数据加载器](#63-创建数据加载器)
- [6.4 使用预训练权重初始化模型](#64-使用预训练权重初始化模型)
- [6.5 添加分类头](#65-添加分类头)
- [6.6 计算分类损失和准确率](#66-计算分类损失和准确率)
- [6.7 使用监督数据对模型进行微调](#67-使用监督数据对模型进行微调)
- [6.8 将 LLM 用于垃圾短信分类](#68-将-llm-用于垃圾短信分类)
- [6.9 本章摘要](#69-本章摘要)
-----
在之前的章节中,我们实现了 LLM 的架构,进行了预训练,并学习了如何从外部来源(如 OpenAI)导入预训练权重。本章将在此基础上,通过微调 LLM 来完成特定目标任务,比如文本分类(见图 6.1)。我们将以一个具体的例子来说明如何将文本消息分类为垃圾短信或正常短信。
图 6.1 展示了微调 LLM 的两种主要方式:用于分类的微调(步骤 8)和用于指令遵循的微调(步骤 9)。在下一节中,我们将深入探讨这两种微调方式。
## 6.1 不同类型的微调
微调语言模型最常见的方法是指令微调和分类微调。指令微调通过在一组任务上使用特定指令训练模型,用以提升模型对自然语言提示中任务描述的理解和执行能力,如图 6.2 所示。
下一章将讨论指令微调,相关内容在图 6.2 中有所展示。而本章的重点是分类微调,如果您有机器学习基础,可能已经对这一概念比较熟悉。
在分类微调中,模型被训练用来识别特定的一组类别标签,比如“垃圾短信”和“非垃圾短信”。分类任务的应用不仅限于 LLM 和电子邮件过滤,还包括从图像中识别不同种类的植物、将新闻分类到体育、政治或科技等主题,以及在医学影像中区分良性和恶性肿瘤。
但有一个关键点需要注意,经过分类微调的模型只能预测训练中遇到的类别。例如,它可以判断某内容是‘垃圾短信’还是‘非垃圾短信’(如图 6.3 所示),但不能对输入文本提供其他方面的信息。
与图6.3中所示的分类微调模型不同,指令微调模型通常可以执行更广泛的任务。分类微调模型可以视为高度专业化的模型,而相比之下,开发一个适用于各种任务的通用型模型通常更具挑战性。
> [!NOTE]
>
> **选择合适的微调方式**
>
> 指令微调提升了模型基于用户指令进行理解和生成响应的能力。它适用于需要基于复杂用户指令处理多任务的模型,增强模型的灵活性和交互质量。而分类微调则适合需要将数据精确分类为预定义类别的任务,例如情感分析或垃圾短信检测。
>
> 虽然指令微调用途更广泛,但需要更大的数据集和更多的计算资源,才能训练出能胜任多种任务的模型。相比之下,分类微调所需的数据和计算量更少,但用途局限于模型已训练的特定类别。
## 6.2 准备数据集
在本章的剩余部分,我们将对之前章节中实现并预训练的 GPT 模型进行修改和分类微调。我们从下载并准备数据集开始,如图 6.4 所示。
为了提供一个直观实用的分类微调示例,我们将采用一个包含垃圾消息和非垃圾消息的文本消息数据集。
注意,这里讨论的是通过手机发送的短信,而不是电子邮件。不过,相同的步骤也适用于电子邮件分类,感兴趣的读者可以在附录 B 的参考部分找到邮件垃圾分类数据集的链接。
首先,通过以下代码下载数据集:
```python
# Listing 6.1 Downloading and unzipping the dataset
import urllib.request
import zipfile
import os
from pathlib import Path
url = "https://archive.ics.uci.edu/static/public/228/sms+spam+collection.zip"
zip_path = "sms_spam_collection.zip"
extracted_path = "sms_spam_collection"
data_file_path = Path(extracted_path) / "SMSSpamCollection.tsv"
def download_and_unzip_spam_data(url, zip_path, extracted_path, data_file_path):
if data_file_path.exists():
print(f"{data_file_path} already exists. Skipping download and extraction.")
return
with urllib.request.urlopen(url) as response: #A
with open(zip_path, "wb") as out_file:
out_file.write(response.read())
with zipfile.ZipFile(zip_path, "r") as zip_ref: #B
zip_ref.extractall(extracted_path)
original_file_path = Path(extracted_path) / "SMSSpamCollection"
os.rename(original_file_path, data_file_path) #C
print(f"File downloaded and saved as {data_file_path}")
download_and_unzip_spam_data(url, zip_path, extracted_path, data_file_path)
#A 下载数据集
#B 解压数据集
#C 为解压的数据集文件设置.csv文件扩展名
```
执行完上述代码后,数据集被保存为制表符分隔的文本文件“SMSSpamCollection.tsv”,位于“sms_spam_collection”文件夹中。我们可以将其加载到 pandas DataFrame 中,方法如下:
```python
import pandas as pd
df = pd.read_csv(data_file_path, sep="\t", header=None, names=["Label", "Text"])
df #A
#A 在 Jupyter Notebook 中可以直接渲染数据,或者用 print(df) 命令显示数据内容
```
保存的数据集如图 6.5 所示:
我们来看一下数据集中类别标签的分布情况:
```python
print(df["Label"].value_counts())
```
执行上述代码后,我们发现数据集中‘ham’(正常短信)比‘spam’(垃圾短信)出现频率更高:
```python
Label
ham 4825
spam 747
Name: count, dtype: int64
```
为了简化起见,同时也因为我们倾向于使用小数据集进行教学(这便于更快地微调 LLM),我们选择对数据集进行下采样,每个类别保留 747 个样本。尽管处理类别不平衡的方法有多种,但这超出了本书关于 LLM 的讨论范围。读者若有兴趣探索处理不平衡数据的方法,可以参考附录 B 的参考部分。
我们可以通过以下代码对数据集进行下采样,以创建一个平衡的数据集:
```python
# Listing 6.2 Creating a balanced dataset
def create_balanced_dataset(df):
num_spam = df[df["Label"] == "spam"].shape[0] #A
ham_subset = df[df["Label"] == "ham"].sample(num_spam, random_state=123) #B
balanced_df = pd.concat([ham_subset, df[df["Label"] == "spam"]]) #C
return balanced_df
balanced_df = create_balanced_dataset(df)
print(balanced_df["Label"].value_counts())
#A 统计垃圾短信的实例数量
#B 随机抽取正常邮件实例,使其数量与垃圾短信实例相同。
#C 将正常短信子集与垃圾短信合并
```
在执行了以上代码以平衡数据集后,我们可以看到现在垃圾短信和正常短信的数量相等。
```python
Label
ham 747
spam 747
Name: count, dtype: int64
```
接下来,我们将字符串类别标签 "ham" 和 "spam" 分别转换为整数类别标签 0 和 1:
```python
balanced_df["Label"] = balanced_df["Label"].map({"ham": 0, "spam": 1})
```
这个过程类似于将文本转换为 token ID,但与使用包含 5 万多个词的 GPT 词汇表不同,这里我们仅处理两个 token ID:0 和 1。
我们还需创建一个`random_split`函数,将数据集划分为三部分:70%用于训练,10%用于验证,20%用于测试。这些比例是机器学习中用于训练、调整和评估模型的常见划分比例:
```python
# Listing 6.3 Splitting the dataset
def random_split(df, train_frac, validation_frac):
df = df.sample(frac=1, random_state=123).reset_index(drop=True) #A
train_end = int(len(df) * train_frac) #B
validation_end = train_end + int(len(df) * validation_frac)
train_df = df[:train_end] #C
validation_df = df[train_end:validation_end]
test_df = df[validation_end:]
return train_df, validation_df, test_df
train_df, validation_df, test_df = random_split(balanced_df, 0.7, 0.1) #D
#A 将整个 DataFrame 随机打乱
#B 计算数据分割的索引
#C 分割 DataFrame
#D 测试集默认大小为 0.2(即剩余部分)
```
此外,我们将数据集保存为 CSV 文件,以便后续复用:
```python
train_df.to_csv("train.csv", index=None)
validation_df.to_csv("validation.csv", index=None)
test_df.to_csv("test.csv", index=None)
```
本节中,我们已经完成了数据集的下载、数据平衡处理,并将其划分为训练集和验证集。在接下来的部分中,我们将设置用于模型训练的 PyTorch 数据加载器。
## 6.3 创建数据加载器
在本节中,我们将开发 PyTorch 数据加载器,其概念与第 2 章中实现的加载器类似。
在第2章中,我们使用滑动窗口技术生成了大小一致的文本块,并将它们分组成批次,以提高模型训练的效率。每个文本块都作为一个独立的训练实例。
然而,本章中我们使用的垃圾短信数据集包含长度不一的文本消息。为了像第 2 章中的文本块那样对这些消息进行批处理,我们有两种处理方式:
1. 将所有消息截断至数据集或批次中最短消息的长度。
2. 将所有消息填充到数据集或批次中最长消息的长度。
方案一的计算成本较低,但如果较短的消息远小于平均长度或最长消息长度,可能会导致显著的信息损失,从而降低模型的性能。因此,我们选择方案二,以完整保留所有消息的内容。
为实现方案二,我们需要将所有消息填充到与数据集中最长消息相同的长度,对所有较短的消息添加填充 token。为此,我们使用 `"<|endoftext|>"` 作为填充 token,正如第 2 章中所讨论的。
在实现细节上,我们可以在编码后的文本消息中添加与 `"<|endoftext|>"` 对应的 token ID,而不是直接将字符串 `"<|endoftext|>"` 附加到每条文本消息后,如图 6.6 所示。
图 6.6 假定 50,256 是填充 token `<|endoftext|>` 的 token ID。我们可以通过使用 tiktoken 包中的 GPT-2 分词器对 `<|endoftext|>` 进行编码来进一步验证此 token ID 是否正确(该分词器在前几章中已使用过):
```python
import tiktoken
tokenizer = tiktoken.get_encoding("gpt2")
print(tokenizer.encode("<|endoftext|>", allowed_special={"<|endoftext|>"}))
```
执行以上代码,我们发现确实返回了 `[50256]`。
接着,我们需要实例化数据加载器。但在此之前,我们首先需要实现一个 PyTorch Dataset,用于定义数据的加载和处理方式。
为此,我们定义了`SpamDataset`类,实现了图 6.6 中展示的概念。该类负责处理多个关键任务:它识别训练数据集中最长的序列,对文本消息进行编码,并确保通过填充 token 将其他序列补齐到与最长序列相同的长度。
```python
# Listing 6.4 Setting up a Pytorch Dataset class
import torch
from torch.utils.data import Dataset
class SpamDataset(Dataset):
def __init__(self, csv_file, tokenizer, max_length=None, pad_token_id=50256):
self.data = pd.read_csv(csv_file)
self.encoded_texts = [ #A
tokenizer.encode(text) for text in self.data["Text"]
]
if max_length is None:
self.max_length = self._longest_encoded_length()
else:
self.max_length = max_length
self.encoded_texts = [ #B
encoded_text[:self.max_length]
for encoded_text in self.encoded_texts
]
self.encoded_texts = [ #C
encoded_text + [pad_token_id] * (self.max_length - len(encoded_text))
for encoded_text in self.encoded_texts
]
def __getitem__(self, index):
encoded = self.encoded_texts[index]
label = self.data.iloc[index]["Label"]
return (
torch.tensor(encoded, dtype=torch.long),
torch.tensor(label, dtype=torch.long)
)
def __len__(self):
return len(self.data)
def _longest_encoded_length(self):
max_length = 0
for encoded_text in self.encoded_texts:
encoded_length = len(encoded_text)
if encoded_length > max_length:
max_length = encoded_length
return max_length
#A 对文本进行预分词
#B 若序列超过最大长度则进行截断
#C 将序列填充至最长序列长度
```
`SpamDataset`类从之前创建的 CSV 文件中加载数据,使用 tiktoken 库中的 GPT-2 分词器对文本进行分词,并支持将序列填充或截断为统一长度(由最长序列或预定义的最大长度决定)。这样可以确保每个输入张量大小一致,从而满足接下来数据加载器创建批量训练数据的需求:
```python
train_dataset = SpamDataset(
csv_file="train.csv",
max_length=None,
tokenizer=tokenizer
)
```
请注意,数据集的 `max_length` 属性中存储了最大序列长度。如果想要查看最长序列的 token 数量,可以使用以下代码:
```python
print(train_dataset.max_length)
```
代码输出了 120,表明最长的序列不超过 120 个 token,这也是文本消息的常见长度。值得注意的是,我们之前预训练的模型的上下文长度限制为 1,024 个 token,因此可以处理最长 1,024 个 token 的序列。如果数据集中包含更长的文本,可以在创建训练数据集时传入 `max_length=1024` 参数,以确保数据不会超出模型支持的输入(上下文)长度。
接下来,我们将验证集和测试集的序列填充到与训练集中最长序列相同的长度。需要注意的是,如果验证集和测试集中的某些样本长度超过了训练集中最长样本的长度,会在先前定义的 `SpamDataset` 代码中通过 `encoded_text[:self.max_length]` 进行截断。这种截断是可选的;如果确保验证集和测试集中没有超过 1,024 个 token 的序列,也可以将 `max_length` 设置为 `None` 来避免截断。
```python
val_dataset = SpamDataset(
csv_file="validation.csv",
max_length=train_dataset.max_length,
tokenizer=tokenizer
)
test_dataset = SpamDataset(
csv_file="test.csv",
max_length=train_dataset.max_length,
tokenizer=tokenizer
)
```
> [!NOTE]
>
> 练习6.1 扩展上下文长度
>
> 将输入补齐到模型支持的最大 token 数量,并观察其对预测性能的影响。
将以上的数据集作为输入,我们就可以实例化数据加载器(可以回顾第 2 章中的操作)。然而,在本例中,目标表示的是类别标签,而非文本中的下一个 token。例如,选择批量大小为 8 时,每个批次包含 8 个长度为 120 的训练样本和相应的类别标签,如图 6.7 所示。
以下代码创建了训练集、验证集和测试集的数据加载器,以批量大小为 8 加载文本消息及其标签(如图 6.7 所示):
```python
# Listing 6.5 Creating PyTorch data loaders
from torch.utils.data import DataLoader
num_workers = 0 #A
batch_size = 8
torch.manual_seed(123)
train_loader = DataLoader(
dataset=train_dataset,
batch_size=batch_size,
shuffle=True,
num_workers=num_workers,
drop_last=True,
)
val_loader = DataLoader(
dataset=val_dataset,
batch_size=batch_size,
num_workers=num_workers,
drop_last=False,
)
test_loader = DataLoader(
dataset=test_dataset,
batch_size=batch_size,
num_workers=num_workers,
drop_last=False,
)
#A 此设置可确保与大多数计算机兼容
```
为了确保数据加载器正常工作并确实返回了预期大小的批次数据,我们可以遍历训练集数据加载器,并打印最后一个批次的张量维度:
```python
for input_batch, target_batch in train_loader:
pass
print("Input batch dimensions:", input_batch.shape)
print("Label batch dimensions", target_batch.shape)
```
输出如下:
```python
Input batch dimensions: torch.Size([8, 120])
Label batch dimensions torch.Size([8])
```
如上所示,输入批次包含 8 个训练样本,每个样本包含 120 个token。标签张量存储了对应 8 个训练样本的类别标签。
最后,为了了解数据集的大小,可以打印每个数据集的批次数:
```python
print(f"{len(train_loader)} training batches")
print(f"{len(val_loader)} validation batches")
print(f"{len(test_loader)} test batches")
```
各数据集的批次数如下:
```python
130 training batches
19 validation batches
38 test batches
```
本章的数据准备工作到此结束,接下来我们将初始化模型以准备进行微调。
## 6.4 使用预训练权重初始化模型
在本节中,我们将准备用于垃圾短信分类微调的模型。首先,我们初始化上一章使用过的预训练模型,如图 6.8 所示。
现在我们通过复用第 5 章的配置,开始进行模型准备过程:
```python
CHOOSE_MODEL = "gpt2-small (124M)"
INPUT_PROMPT = "Every effort moves"
BASE_CONFIG = {
"vocab_size": 50257, # Vocabulary size
"context_length": 1024, # Context length
"drop_rate": 0.0, # Dropout rate
"qkv_bias": True # Query-key-value bias
}
model_configs = {
"gpt2-small (124M)": {"emb_dim": 768, "n_layers": 12, "n_heads": 12},
"gpt2-medium (355M)": {"emb_dim": 1024, "n_layers": 24, "n_heads": 16},
"gpt2-large (774M)": {"emb_dim": 1280, "n_layers": 36, "n_heads": 20},
"gpt2-xl (1558M)": {"emb_dim": 1600, "n_layers": 48, "n_heads": 25},
}
BASE_CONFIG.update(model_configs[CHOOSE_MODEL])
assert train_dataset.max_length <= BASE_CONFIG["context_length"], (
f"Dataset length {train_dataset.max_length} exceeds model's context "
f"length {BASE_CONFIG['context_length']}. Reinitialize data sets with "
f"`max_length={BASE_CONFIG['context_length']}`"
)
```
接下来,我们从第 5 章下载的 `gpt_download.py` 文件中导入 `download_and_load_gpt2` 函数。同时,我们还可以复用第 5 章中的 `GPTModel` 类和 `load_weights_into_gpt` 函数,将下载的权重加载到 GPT 模型中:
```python
# Listing 6.6 Loading a pretrained GPT model
from gpt_download import download_and_load_gpt2
from chapter05 import GPTModel, load_weights_into_gpt
model_size = CHOOSE_MODEL.split(" ")[-1].lstrip("(").rstrip(")")
settings, params = download_and_load_gpt2(model_size=model_size, models_dir="gpt2")
model = GPTModel(BASE_CONFIG)
load_weights_into_gpt(model, params)
model.eval()
```
在将模型权重加载到`GPTModel`后,我们使用前面章节的文本生成工具函数,确保模型能够生成连贯的文本:
```python
from chapter04 import generate_text_simple
from chapter05 import text_to_token_ids, token_ids_to_text
text_1 = "Every effort moves you"
token_ids = generate_text_simple(
model=model,
idx=text_to_token_ids(text_1, tokenizer),
max_new_tokens=15,
context_size=BASE_CONFIG["context_length"]
)
print(token_ids_to_text(token_ids, tokenizer))
```
从以下输出可以看出,模型生成了连贯的文本,这表明模型权重已正确加载:
```python
Every effort moves you forward.
The first step is to understand the importance of your work
```
现在,在我们开始将模型微调为垃圾短信分类器之前,我们先来看看这个模型是否能通过给它提供指令来对垃圾短信进行分类:
```python
text_2 = (
"Is the following text 'spam'? Answer with 'yes' or 'no':"
" 'You are a winner you have been specially"
" selected to receive $1000 cash or a $2000 award.'"
)
token_ids = generate_text_simple(
model=model,
idx=text_to_token_ids(text_2, tokenizer),
max_new_tokens=23,
context_size=BASE_CONFIG["context_length"]
)
print(token_ids_to_text(token_ids, tokenizer))
```
模型输出如下:
```python
Is the following text 'spam'? Answer with 'yes' or 'no': 'You are a winner you have been
specially selected to receive $1000 cash or a $2000 award.'
The following text 'spam'? Answer with 'yes' or 'no': 'You are a winner
```
根据输出结果,可以看到模型还不具备遵循指令方面的能力。
这是预料之中的,因为它仅经过了预训练,缺乏指令微调,我们将在下一章探讨这个问题。
下一节开始为模型的分类微调做准备。
## 6.5 添加分类头
本节我们将修改预训练的模型,为分类任务的微调做准备。为此,我们需要替换原始输出层,原输出层将隐层表示映射到50,257个词汇的词汇表,而我们用一个较小的输出层将其映射到两个类别:0(‘非垃圾短信’)和1(‘垃圾短信’),如图6.9所示。
如图 6.9 所示,我们使用与前几章相同的模型,唯一的不同是替换了输出层。
> [!NOTE]
>
> **输出层节点**
>
> 理论上,由于我们处理的是二分类任务,可以使用单个输出节点。然而,这需要修改损失函数,具体内容可以参见附录B的参考部分。因此,我们选择一个更通用的方法,即输出节点数与类别数相匹配。例如,对于一个三分类问题,如将新闻文章分类为“技术”、“体育”或“政治”,我们使用三个输出节点,以此类推。
在我们尝试图 6.9 中展示的修改之前,先通过 `print(model)` 打印模型架构,结果如下:
```python
GPTModel(
(tok_emb): Embedding(50257, 768)
(pos_emb): Embedding(1024, 768)
(drop_emb): Dropout(p=0.0, inplace=False)
(trf_blocks): Sequential(
...
(11): TransformerBlock(
(att): MultiHeadAttention(
(W_query): Linear(in_features=768, out_features=768, bias=True)
(W_key): Linear(in_features=768, out_features=768, bias=True)
(W_value): Linear(in_features=768, out_features=768, bias=True)
(out_proj): Linear(in_features=768, out_features=768, bias=True)
(dropout): Dropout(p=0.0, inplace=False)
)
(ff): FeedForward(
(layers): Sequential(
(0): Linear(in_features=768, out_features=3072, bias=True)
(1): GELU()
(2): Linear(in_features=3072, out_features=768, bias=True)
)
)
(norm1): LayerNorm()
(norm2): LayerNorm()
(drop_resid): Dropout(p=0.0, inplace=False)
)
)
(final_norm): LayerNorm()
(out_head): Linear(in_features=768, out_features=50257, bias=False)
)
```
上图清晰展示了我们在第 4 章实现的架构:GPT 模型由嵌入层、12 个相同的 Transformer 模块(出于简洁考虑,只展示了最后一个模块)构成,接着是最终的 LayerNorm 层和输出层(out_head)。
接下来我们将用一个新的输出层替换原始输出层(见图 6.9),并对其进行微调。
> [!NOTE]
>
> **微调部分层与全部层的对比**
>
> 由于我们从预训练模型开始,并不需要对所有模型层进行微调。这是因为,在基于神经网络的语言模型中,低层通常捕捉到的是基本的语言结构和语义,这些特征适用于多种任务和数据集。因此,只微调最后几层(接近输出层),它们更专注于细致的语言模式和任务特定的特征,通常就足够使模型适应新任务。此外,微调较少的层在计算上也更加高效。对于有兴趣的读者,可以在附录B的参考部分找到更多关于微调哪些层的详细信息,包括相关实验。
为了让模型准备好进行分类微调,我们首先通过将所有层设为不可训练来冻结模型:
```python
for param in model.parameters():
param.requires_grad = False
```
接着,按照图 6.9 所示,我们替换掉输出层(model.out_head),该层原本将层输入映射到 50,257 维空间(即词汇表大小):
```python
# Listing 6.7 Adding a classification layer
torch.manual_seed(123)
num_classes = 2
model.out_head = torch.nn.Linear(
in_features=BASE_CONFIG["emb_dim"],
out_features=num_classes
)
```
请注意,在上述代码中我们使用了 `BASE_CONFIG["emb_dim"]`,在 `gpt2-small (124M)` 模型中它的值为 768,这样可以让后续代码更加通用,便于适配更大的 GPT-2 模型变体。
这个新的输出层 `model.out_head` 的 `requires_grad` 属性默认为 `True`,意味着它是模型训练过程中唯一会被更新的层。
从技术上讲,训练我们刚添加的输出层已经足够。然而,通过实验我发现,微调更多层能够显著提升微调后模型的预测性能(更多细节请参考附录 C 中的参考文献)。
此外,我们还需将最后一个 Transformer 模块以及连接该模块和输出层的 LayerNorm 模块配置为可训练,如图6.10所示。
为了让最终的 LayerNorm 和最后一个 Transformer 模块参与训练(如图 6.10 所示),我们将它们的 `requires_grad` 设置为 `True:`
```python
for param in model.trf_blocks[-1].parameters():
param.requires_grad = True
for param in model.final_norm.parameters():
param.requires_grad = True
```
> [!NOTE]
>
> **微调整个模型**
>
> 与仅微调最后一个 Transformer 模块相比,可以微调整个模型并评估其对预测性能的影响。
尽管我们增加了一个新的输出层,并标记了某些层为可训练或不可训练,我们仍然可以像前几章那样使用这个模型。例如,我们可以像以前一样向模型输入一个示例文本。考虑以下示例文本:
```python
inputs = tokenizer.encode("Do you have time")
inputs = torch.tensor(inputs).unsqueeze(0)
print("Inputs:", inputs)
print("Inputs dimensions:", inputs.shape) # shape: (batch_size, num_tokens)
```
从输出结果可以看出,前面的代码将输入编码成了一个包含 4 个输入 token 的张量:
```python
Inputs: tensor([[5211, 345, 423, 640]])
Inputs dimensions: torch.Size([1, 4])
```
接着,我们将编码后的 token ID 直接传入模型:
```python
with torch.no_grad():
outputs = model(inputs)
print("Outputs:\n", outputs)
print("Outputs dimensions:", outputs.shape) # shape: (batch_size, num_tokens, num_classes)
```
输出张量如下所示:
```python
Outputs:
tensor([[[-1.5854, 0.9904],
[-3.7235, 7.4548],
[-2.2661, 6.6049],
[-3.5983, 3.9902]]])
Outputs dimensions: torch.Size([1, 4, 2])
```
在第 4 章和第 5 章中,相似的输入会生成形状为 [1, 4, 50257] 的输出张量,其中 50,257 表示词汇表大小。与前几章相同,输出张量的行数对应输入的 token 数量(在这里是 4 个)。不过,由于替换了模型的输出层,现在每个输出的嵌入维度(即列数)从 50,257 缩减为 2。
请注意,我们希望微调该模型,使其能够输出一个分类标签,用于判断输入是否为垃圾短信。为实现这一点,我们不需要微调所有 4 行输出,只需聚焦于单个输出 token。具体来说,我们将重点关注最后一行对应的输出 token,如图 6.11 所示。
```python
# To extract the last output token, illustrated in figure 6.11, from the output tensor, we use the following code:
print("Last output token:", outputs[:, -1, :])
```
输出如下:
```python
Last output token: tensor([[-3.5983, 3.9902]])
```
接下来,我们将重点讨论如何将这些值转换为类别标签预测。但在此之前,我们需要理解,为什么我们特别关注最后一个输出的token,而不是第一个、第二个或第三个输出token。
在第 3 章中,我们探讨了注意力机制,该机制在每个输入 token 与其他所有输入 token 之间建立关系。随后,我们引入了因果注意力掩码的概念,这在 GPT 类模型中被广泛使用。这种掩码限制每个 token 的关注范围,使其只能关注当前位置及之前的内容,从而确保每个 token 只能受到自身及前面 token 的影响,如图 6.12 所示。
在图 6.12 所示的因果注意力掩码设置中,序列中的最后一个 token 聚合了所有前面 token 的信息。因此,在垃圾短信分类任务的微调过程中,我们会重点关注这个最后的 token。
在修改模型后,接下来将详细介绍如何将最后一个 token 转换为分类标签预测,并计算模型的初始预测准确率。之后,我们将在后续部分对模型进行垃圾短信分类任务的微调。
> [!NOTE]
>
> **第一个 token 与最后一个 token 的微调对比**
>
> 尝试微调第一个输出 token,而不是最后一个输出 token,并在后续章节的模型微调实验中观察预测性能的变化。
## 6.6 计算分类损失和准确率
本章到目前为止,我们已完成了数据集准备、预训练模型的加载,以及对模型进行分类微调的修改。在微调正式开始前,还剩下一小部分工作:实现微调过程中使用的模型评估函数(如图 6.13 所示)。我们将在本节完成这一部分。
在实现评估工具之前,我们先简单讨论一下如何将模型输出转换为类别标签预测。
在上一章中,我们通过 softmax 函数将 50,257 个输出转换为概率分布,然后通过 argmax 函数返回概率最高的位置,从而得到 LLM 生成的下一个 token 的 token ID。本章中,我们采用相同的方法来计算模型对于给定输入的预测结果是‘垃圾短信’还是‘正常短信’。唯一的区别是,这次的输出维度是 2,而不是 50,257 维。
模型对每个输入文本的最后一个 token 生成的输出被转换为概率得分。然后,通过查找概率得分中最高值的位置来确定对应的分类标签。请注意,由于模型尚未经过训练,目前对垃圾短信标签的预测是不准确的。
为了通过具体示例来说明图 6.14,我们来看一下前一节代码示例中的最后一个输出 token:
```python
print("Last output token:", outputs[:, -1, :])
```
以下是最后一个 token 对应的张量值:
```python
Last output token: tensor([[-3.5983, 3.9902]])
```
我们可以通过以下代码获取分类标签:
```python
probas = torch.softmax(outputs[:, -1, :], dim=-1)
label = torch.argmax(probas)
print("Class label:", label.item())
```
在这种情况下,代码返回 1,表示模型预测输入文本为‘垃圾短信’。这里使用 Softmax 函数是可选的,因为最大的输出值已经对应最高的概率分数(参见第 5 章)。因此,我们可以省略 Softmax 函数,简化代码如下:
```python
logits = outputs[:, -1, :]
label = torch.argmax(logits)
print("Class label:", label.item())
```
这个概念可以用来计算分类准确率,它衡量的是数据集上正确预测的比例。
为了计算分类准确率,我们对数据集中的所有样本进行 argmax 预测,并通过定义一个 `calc_accuracy_loader` 函数来计算预测正确的比例:
```python
# Listing 6.8 Calculating the classification accuracy
def calc_accuracy_loader(data_loader, model, device, num_batches=None):
model.eval()
correct_predictions, num_examples = 0, 0
if num_batches is None:
num_batches = len(data_loader)
else:
num_batches = min(num_batches, len(data_loader))
for i, (input_batch, target_batch) in enumerate(data_loader):
if i < num_batches:
input_batch, target_batch = input_batch.to(device), target_batch.to(device)
with torch.no_grad():
logits = model(input_batch)[:, -1, :] #A
predicted_labels = torch.argmax(logits, dim=-1)
num_examples += predicted_labels.shape[0]
correct_predictions += (predicted_labels == target_batch).sum().item()
else:
break
return correct_predictions / num_examples
#A 最后一个输出 token 的 logits 值
```
我们可以使用这个函数来估算多个数据集上的分类准确率,为提高效率,这里基于 10 个批次的结果进行估算:
```python
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model.to(device)
torch.manual_seed(123)
train_accuracy = calc_accuracy_loader(train_loader, model, device, num_batches=10)
val_accuracy = calc_accuracy_loader(val_loader, model, device, num_batches=10)
test_accuracy = calc_accuracy_loader(test_loader, model, device, num_batches=10)
print(f"Training accuracy: {train_accuracy*100:.2f}%")
print(f"Validation accuracy: {val_accuracy*100:.2f}%")
print(f"Test accuracy: {test_accuracy*100:.2f}%")
```
通过设置`device`属性,如果检测到支持 Nvidia CUDA 的 GPU,模型会自动在 GPU 上运行,否则会在 CPU 上运行。输出如下:
```python
Training accuracy: 46.25%
Validation accuracy: 45.00%
Test accuracy: 48.75%
```
可以看到,当前模型的预测准确率接近随机预测(在本例中为 50%)。为了提高预测准确率,我们需要对模型进行微调。
在微调模型之前,我们需要定义损失函数,以便在训练过程中对其进行优化。我们的目标是最大化模型的垃圾短信分类准确率,因此代码输出应为正确的类别标签:0 表示正常短信,1 表示垃圾短信。
然而,由于分类准确率不是一个可微分的函数,因此我们使用交叉熵损失作为替代来优化准确率。这里所说的交叉熵损失与第 5 章讨论的一致。
因此,`calc_loss_batch` 函数与第五章中的版本基本相同,唯一的调整是:我们只优化最后一个 token(`model(input_batch)[:, -1, :]`),而不是整个序列中的所有 token(`model(input_batch)`):
```python
def calc_loss_batch(input_batch, target_batch, model, device):
input_batch, target_batch = input_batch.to(device), target_batch.to(device)
logits = model(input_batch)[:, -1, :] # Logits of last output token
loss = torch.nn.functional.cross_entropy(logits, target_batch)
return loss
```
我们使用 `calc_loss_batch` 函数来计算从前面定义的数据加载器获取的单个批次的损失。为了计算数据加载器中所有批次的损失,我们定义了 `calc_loss_loader` 函数,其功能与第五章中的描述相同。
```python
# Listing 6.9 Calculating the classification loss
def calc_loss_loader(data_loader, model, device, num_batches=None):
total_loss = 0.
if len(data_loader) == 0:
return float("nan")
elif num_batches is None:
num_batches = len(data_loader)
else: #A
num_batches = min(num_batches, len(data_loader))
for i, (input_batch, target_batch) in enumerate(data_loader):
if i < num_batches:
loss = calc_loss_batch(input_batch, target_batch, model, device)
total_loss += loss.item()
else:
break
return total_loss / num_batches
# Similar to calculating the training accuracy, we now compute the initial loss for each data set:
with torch.no_grad(): #B
train_loss = calc_loss_loader(train_loader, model, device, num_batches=5)
val_loss = calc_loss_loader(val_loader, model, device, num_batches=5)
test_loss = calc_loss_loader(test_loader, model, device, num_batches=5)
#A 确保批次数不超过数据加载器中的总批次数
#B 关闭梯度追踪以提高效率,因为当前未进行训练
```
```python
print(f"Training loss: {train_loss:.3f}")
print(f"Validation loss: {val_loss:.3f}")
print(f"Test loss: {test_loss:.3f}")
The initial loss values are as follows:
Training loss: 3.095
Validation loss: 2.583
Test loss: 2.322
```
在下一节,我们将实现一个训练函数来微调模型,实现最小化训练集损失。最小化训练集损失将有助于提高分类准确性,这是我们的总体目标。
## 6.7 使用监督数据对模型进行微调
在本节中,我们定义并使用训练函数,对预训练的 LLM 进行微调,以提升其垃圾短信分类的准确率。训练循环的整体结构与第 5 章中的相同(详见图 6.15),唯一的区别在于,这里计算的是分类准确率,而不是通过生成文本来评估模型。
可以看到,图 6.15 中所示的训练函数逻辑,与第 5 章中用于模型预训练的 `train_model_simple` 函数非常相似。
唯一的两个区别在于:现在记录的是训练样本数量(examples_seen),而不是 token 数量;并且在每个 epoch 后计算准确率,而不再打印示例文本:
```python
# Listing 6.10 Finetuning the model to classify spam
def train_classifier_simple(model, train_loader, val_loader, optimizer, device,
num_epochs, eval_freq, eval_iter, tokenizer):
# Initialize lists to track losses and examples seen
train_losses, val_losses, train_accs, val_accs = [], [], [], []
examples_seen, global_step = 0, -1
# Main training loop
for epoch in range(num_epochs):
model.train() #A
for input_batch, target_batch in train_loader:
optimizer.zero_grad() #B
loss = calc_loss_batch(input_batch, target_batch, model, device)
loss.backward() #C
optimizer.step() #D
examples_seen += input_batch.shape[0] #E
global_step += 1
if global_step % eval_freq == 0: #F
train_loss, val_loss = evaluate_model(
model, train_loader, val_loader, device, eval_iter)
train_losses.append(train_loss)
val_losses.append(val_loss)
print(f"Ep {epoch+1} (Step {global_step:06d}): "
f"Train loss {train_loss:.3f}, Val loss {val_loss:.3f}")
train_accuracy = calc_accuracy_loader( #G
train_loader, model, device, num_batches=eval_iter
)
val_accuracy = calc_accuracy_loader(
val_loader, model, device, num_batches=eval_iter
)
print(f"Training accuracy: {train_accuracy*100:.2f}% | ", end="")
print(f"Validation accuracy: {val_accuracy*100:.2f}%")
train_accs.append(train_accuracy)
val_accs.append(val_accuracy)
return train_losses, val_losses, train_accs, val_accs, examples_seen
#A 设置模型为训练模式
#B 重置上一批次的损失梯度
#C 计算损失梯度
#D 使用损失梯度更新模型权重
#E 更改逻辑:跟踪样本数量而非 token 数量
#F 可选评估步骤
#G 每个 epoch 后计算准确率
```
以上 `train_classifier_simple` 中使用的 `evaluate_model` 函数与我们在第 5 章中使用的函数相同:
```python
def evaluate_model(model, train_loader, val_loader, device, eval_iter):
model.eval()
with torch.no_grad():
train_loss = calc_loss_loader(train_loader, model, device, num_batches=eval_iter)
val_loss = calc_loss_loader(val_loader, model, device, num_batches=eval_iter)
model.train()
return train_loss, val_loss
```
接下来,我们初始化优化器,设置训练轮数,并通过 `train_classifier_simple` 函数启动训练。关于训练轮数的选择将在评估结果后讨论。在 M3 MacBook Air 上训练大约需要 6 分钟,而在 V100 或 A100 GPU 上则不到半分钟:
```python
import time
start_time = time.time()
torch.manual_seed(123)
optimizer = torch.optim.AdamW(model.parameters(), lr=5e-5, weight_decay=0.1)
num_epochs = 5
train_losses, val_losses, train_accs, val_accs, examples_seen = train_classifier_simple(
model, train_loader, val_loader, optimizer, device,
num_epochs=num_epochs, eval_freq=50, eval_iter=5,
tokenizer=tokenizer
)
end_time = time.time()
execution_time_minutes = (end_time - start_time) / 60
print(f"Training completed in {execution_time_minutes:.2f} minutes.")
```
训练过程中的输出如下:
```python
Ep 1 (Step 000000): Train loss 2.153, Val loss 2.392
Ep 1 (Step 000050): Train loss 0.617, Val loss 0.637
Ep 1 (Step 000100): Train loss 0.523, Val loss 0.557
Training accuracy: 70.00% | Validation accuracy: 72.50%
Ep 2 (Step 000150): Train loss 0.561, Val loss 0.489
Ep 2 (Step 000200): Train loss 0.419, Val loss 0.397
Ep 2 (Step 000250): Train loss 0.409, Val loss 0.353
Training accuracy: 82.50% | Validation accuracy: 85.00%
Ep 3 (Step 000300): Train loss 0.333, Val loss 0.320
Ep 3 (Step 000350): Train loss 0.340, Val loss 0.306
Training accuracy: 90.00% | Validation accuracy: 90.00%
Ep 4 (Step 000400): Train loss 0.136, Val loss 0.200
Ep 4 (Step 000450): Train loss 0.153, Val loss 0.132
Ep 4 (Step 000500): Train loss 0.222, Val loss 0.137
Training accuracy: 100.00% | Validation accuracy: 97.50%
Ep 5 (Step 000550): Train loss 0.207, Val loss 0.143
Ep 5 (Step 000600): Train loss 0.083, Val loss 0.074
Training accuracy: 100.00% | Validation accuracy: 97.50%
Training completed in 5.65 minutes.
```
类似于第 5 章的做法,我们使用 matplotlib 绘制训练集和验证集的损失函数:
```python
# Listing 6.11 Plotting the classification loss
import matplotlib.pyplot as plt
def plot_values(epochs_seen, examples_seen, train_values, val_values, label="loss"):
fig, ax1 = plt.subplots(figsize=(5, 3))
ax1.plot(epochs_seen, train_values, label=f"Training {label}") #A
ax1.plot(epochs_seen, val_values, linestyle="-.", label=f"Validation {label}")
ax1.set_xlabel("Epochs")
ax1.set_ylabel(label.capitalize())
ax1.legend()
ax2 = ax1.twiny() #B
ax2.plot(examples_seen, train_values, alpha=0) # Invisible plot for aligning ticks
ax2.set_xlabel("Examples seen")
fig.tight_layout() #C
plt.savefig(f"{label}-plot.pdf")
plt.show()
#A 绘制训练轮次与训练和验证损失的变化图
#B 创建一个新的 x 轴,用于显示已处理样本数
#C 调整布局以留出空间
```
```python
epochs_tensor = torch.linspace(0, num_epochs, len(train_losses))
examples_seen_tensor = torch.linspace(0, examples_seen, len(train_losses))
plot_values(epochs_tensor, examples_seen_tensor, train_losses, val_losses)
```
图6.16展示了最终的损失曲线。
从图 6.16 中陡峭的下降曲线可以看出,模型在训练数据上的学习效果很好,且没有明显的过拟合迹象,训练集和验证集的损失值几乎没有差距。
> [!NOTE]
>
> **选择训练轮数**
>
> 在训练开始时,我们将 epoch 数量设置为 5。epoch 的具体数量取决于数据集和任务的难度,并没有通用的解决方案或推荐值。5 个 epoch 通常是一个合适的起点。如果在前几个 epoch 后模型出现过拟合迹象(如图 6.16 所示的损失曲线显示验证损失上升),我们可能需要减少 epoch 数量。相反,如果趋势线显示验证损失随着训练仍有下降空间,我们则应增加 epoch 数量。在本例中,5 个 epoch 是合理的选择,因为没有早期过拟合的迹象,且验证损失接近 0。
接下来,继续使用 plot_values 函数绘制分类准确率的图表:
```python
epochs_tensor = torch.linspace(0, num_epochs, len(train_accs))
examples_seen_tensor = torch.linspace(0, examples_seen, len(train_accs))
plot_values(epochs_tensor, examples_seen_tensor, train_accs, val_accs, label="accuracy")
The resulting accuracy graphs are shown in figure 6.17.
```
从图 6.17 的准确率曲线可以看出,模型在第 4 到 5 个训练周期后,训练和验证准确率均达到了较高水平。
需要注意的是,我们之前在使用 `train_classifier_simple` 函数时将 `eval_iter` 设置为 5,这意味着我们的训练和验证性能估计仅基于 5 个批次,目的是为了提高训练效率。
现在,我们将通过运行以下代码,计算整个数据集在训练集、验证集和测试集上的性能指标,这次不需要定义 `eval_iter` 值:
```python
train_accuracy = calc_accuracy_loader(train_loader, model, device)
val_accuracy = calc_accuracy_loader(val_loader, model, device)
test_accuracy = calc_accuracy_loader(test_loader, model, device)
print(f"Training accuracy: {train_accuracy*100:.2f}%")
print(f"Validation accuracy: {val_accuracy*100:.2f}%")
print(f"Test accuracy: {test_accuracy*100:.2f}%")
```
由此得到的准确率值如下:
```python
Training accuracy: 97.21%
Validation accuracy: 97.32%
Test accuracy: 95.67%
```
可以看到,训练集和测试集的表现几乎相同。
训练集和测试集准确率之间的轻微差异表明训练数据的过拟合程度较低。通常,验证集的准确率会略高于测试集的准确率,这是因为模型开发过程中通常会通过调整超参数来优化验证集上的表现,而这种优化未必能有效地泛化到测试集上。
这种情况很常见,但可以通过调整模型设置来减小这种差距,比如增加 dropout 率(`drop_rate`)或优化器配置中的权重衰减(`weight_decay`)参数。
## 6.8 将 LLM 用于垃圾短信分类
在前几节对模型进行微调和评估后,我们现在进入本章的最后阶段(见图 6.18):使用模型进行垃圾短信分类。
最后,我们将使用微调后的基于 GPT 的垃圾短信分类模型。以下的 `classify_review` 函数遵循了与本章之前实现的 `SpamDataset` 类似的数据预处理步骤。函数先将文本处理为 token ID,然后使用模型预测一个整数类别标签(与 6.6 节中的实现类似),并返回对应的类别名称:
```python
# Listing 6.12 Using the model to classify new texts
def classify_review(text, model, tokenizer, device, max_length=None, pad_token_id=50256):
model.eval()
input_ids = tokenizer.encode(text) #A
supported_context_length = model.pos_emb.weight.shape[1]
input_ids = input_ids[:min(max_length, supported_context_length)] #B
input_ids += [pad_token_id] * (max_length - len(input_ids)) #C
input_tensor = torch.tensor(input_ids, device=device).unsqueeze(0) #D
with torch.no_grad(): #E
logits = model(input_tensor)[:, -1, :] #F
predicted_label = torch.argmax(logits, dim=-1).item()
return "spam" if predicted_label == 1 else "not spam" #G
#A 准备模型输入
#B 截断过长序列
#C 填充序列至最长长度
#D 增加批次维度
#E 关闭梯度跟踪,进行模型推理
#F 获取最后一个输出 token 的 logits
#G 返回分类结果
```
我们来试试用示例文本测试 classify_review 函数的效果:
```python
text_1 = (
"You are a winner you have been specially"
" selected to receive $1000 cash or a $2000 award."
)
print(classify_review(
text_1, model, tokenizer, device, max_length=train_dataset.max_length
))
```
训练得到的模型正确预测了‘spam’。接下来,让我们尝试另一个示例:
```python
text_2 = (
"Hey, just wanted to check if we're still on"
" for dinner tonight? Let me know!"
)
print(classify_review(
text_2, model, tokenizer, device, max_length=train_dataset.max_length
))
```
这个实例也一样,模型做出了正确预测并返回了‘非垃圾短信’标签。
最后,为了方便后续重复使用模型,避免再次训练,我们可以使用上一章介绍的 `torch.save` 方法来保存模型:
```python
torch.save(model.state_dict(), "review_classifier.pth")
```
保存后,可以按如下方式加载模型:
```python
model_state_dict = torch.load("review_classifier.pth")
model.load_state_dict(model_state_dict)
```
## 6.9 本章摘要
+ 微调 LLM 有不同的策略,包括分类微调(本章)和指令微调(下一章)。
+ 分类微调是指将 LLM 的输出层替换为一个小型的分类层。
+ 在将文本消息分类为‘垃圾短信’或‘非垃圾短信’的任务中,新的分类层只需要 2 个输出节点;而在之前的章节中,输出节点的数量等于词汇表中的唯一 token 数量,即 50,256。
+ 分类微调任务不是像预训练那样预测下一个词,而是训练模型输出正确的类别标签,例如‘垃圾短信’或‘非垃圾短信’。
+ 在微调阶段,模型的输入是转换为 token ID 的文本,这与预训练阶段类似。
+ 在微调 LLM 之前,我们会加载预训练模型作为基础。
+ 评估分类模型需要计算分类准确率,即正确预测的比例。
+ 微调分类模型时使用的交叉熵损失函数与预训练 LLM 时相同。
The content has been capped at 50000 tokens, and files over NaN bytes have been omitted. The user could consider applying other filters to refine the result. The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.