tiny-llm-demo

Unnamed repository; edit this file 'description' to name the repository.
git clone git://git.beep.wimdupont.com/tiny-llm-demo.git
Log | Files | Refs | README | LICENSE

commit b8bbed94ec63ca023914713f1cec90cc98059c28
Author: beep <beep@wimdupont.com>
Date:   Sun,  5 Apr 2026 12:51:58 +0000

Add tiny LLM learning demos

Diffstat:
A.gitignore | 1+
ALICENSE | 674+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AREADME.md | 264+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acorpus.txt | 25+++++++++++++++++++++++++
Atiny_lm.py | 306+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atiny_modern_lm.py | 274+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atiny_transformer_lm.py | 385+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 1929 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/LICENSE b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) 2024 Wim Dupont + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + <program> Copyright (C) 2024 Wim Dupont + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +<https://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +<https://www.gnu.org/licenses/why-not-lgpl.html>. diff --git a/README.md b/README.md @@ -0,0 +1,264 @@ +# tiny-llm-demo + +A very small language-model project meant to show the raw mechanics behind an +LLM-like system without hiding them behind frameworks. + +These are not real LLMs. They are tiny plain-Python demos that progressively +move from a simple neural next-token model toward transformer-like structure. +The point is to make the moving parts visible: + +- text becomes tokens +- tokens become vectors +- vectors go through a small neural network +- the network predicts the next token +- training nudges weights so the prediction gets better + +## Files + +- `tiny_lm.py`: model, training loop, and text generation +- `tiny_transformer_lm.py`: second version with a minimal self-attention block +- `tiny_modern_lm.py`: forward-only demo of a more modern transformer block +- `corpus.txt`: small training text + +## Run + +```bash +cd /home/dude/repositories/beep/tiny-llm-demo +python3 tiny_lm.py +``` + +Generate from a custom prompt: + +```bash +python3 tiny_lm.py --prompt "language models " --sample-length 200 +``` + +Run the attention-based version: + +```bash +python3 tiny_transformer_lm.py +``` + +Show the learned attention weights over the prompt context: + +```bash +python3 tiny_transformer_lm.py --show-attention +``` + +Inspect a more modern transformer-style block: + +```bash +python3 tiny_modern_lm.py +``` + +## What the code is showing + +The model has: + +1. A character vocabulary +2. Token embeddings +3. A context window that keeps token positions separate +4. A hidden layer with `tanh` +5. An output layer that predicts the next character + +At each training step: + +1. Take a short slice of text as context +2. Ask the model for the next-character probabilities +3. Compare them to the real next character +4. Compute the loss +5. Backpropagate the error +6. Update the weights with SGD + +This is the same high-level story as a real LLM, just drastically smaller and +with a much simpler architecture. + +For the sake of a short demo, the script repeats the small corpus several times +by default so the model can learn visible patterns faster. + +## What it is not showing + +- a full transformer stack +- multi-head attention +- parallel GPU training +- distributed data loading +- instruction tuning +- RLHF / preference optimization +- inference optimization + +Those are important in real systems, but this project is meant to answer the +more basic question: what does it look like before all that complexity is added? + +## Why there are two versions + +`tiny_lm.py` is the simpler baseline. + +- It takes the whole context window and feeds it through a fixed learned network. +- Every position matters only because the code gives it a slot in the input. +- There is no dynamic decision about which earlier token to focus on. + +`tiny_transformer_lm.py` is the more transformer-like version. + +- Each position gets its own vector. +- The last position forms a query. +- Earlier positions produce keys and values. +- Attention scores decide which earlier positions matter for the next prediction. + +That is the conceptual jump toward transformers: instead of using one fixed +computation over the context, the model learns to route information dynamically +based on token-to-token interactions. + +In this toy version you can inspect that directly with `--show-attention`, +which prints the weight assigned to each character position in the current +prompt context. + +`tiny_modern_lm.py` goes one step closer to a real LLM block. + +- layer norm before sublayers +- causal multi-head self-attention +- residual connection after attention +- feed-forward MLP +- residual connection after the MLP +- output projection to next-token logits + +This is much closer to the shape of a modern transformer block, but it is +forward-only. The earlier two scripts are the training demos. + +## Modern LLM Concepts + +If you want a compact mental model, these are the core ideas: + +1. Text is tokenized. +2. Tokens become learned vectors called embeddings. +3. Position information is added so order is preserved. +4. The model applies many stacked transformer blocks. +5. Each block uses self-attention so tokens can dynamically weigh other tokens. +6. Each block also uses a feed-forward sublayer to transform each position. +7. Residual connections help information and gradients flow through deep stacks. +8. Layer normalization or RMSNorm helps stabilize training. +9. The final vectors are projected into logits over the vocabulary. +10. Training minimizes next-token prediction error over huge corpora. + +## What Each Script Represents + +`tiny_lm.py` + +- Shows raw neural next-token training. +- The context is pushed through a fixed learned network. +- Good for understanding random initialization, loss, backprop, and SGD. + +`tiny_transformer_lm.py` + +- Shows the key jump toward transformer behavior. +- The model uses query/key/value attention to weight context positions dynamically. +- Good for understanding why attention is different from a fixed MLP over context. + +`tiny_modern_lm.py` + +- Shows the architectural shape of a more modern LLM block. +- Adds multi-head attention, layer norm, feed-forward layers, and residual paths. +- Good for understanding how modern transformer blocks are composed. + +## What Is Still Missing From These Toys + +Even with the third script, these demos are still missing important parts of a +real modern LLM: + +- many stacked transformer blocks instead of one tiny block +- real training for the full modern block +- subword tokenization such as BPE instead of character-level tokens +- large-scale optimization such as AdamW, schedules, clipping, and mixed precision +- large curated datasets and large-scale data pipelines +- efficient inference features such as KV cache, batching, and quantization +- post-training such as instruction tuning and preference optimization +- deployment and serving systems + +So the rough ladder is: + +- `tiny_lm.py`: neural language model basics +- `tiny_transformer_lm.py`: attention basics +- `tiny_modern_lm.py`: modern transformer block shape + +That gets you much closer to a modern LLM conceptually, even though the scale +and training sophistication are still far away. + +## Mapping To Real LLM Terms + +If you want to connect these demos to standard terminology, use this table: + +- `character vocabulary` / `stoi` / `itos` + Real term: tokenizer vocabulary + In real systems this is usually a subword vocabulary such as BPE tokens, not characters. + +- `token embeddings` + Real term: embedding layer + This is the learned lookup table that turns token IDs into dense vectors. + +- `positional embeddings` + Real term: positional encoding / positional embedding + This gives the model information about token order. + +- `next-character` or `next-token` prediction + Real term: autoregressive language modeling objective + This is the core pretraining task for a GPT-style model. + +- `output projection to logits` + Real term: LM head + This maps the final hidden state into scores over the vocabulary. + +- `fixed learned network over context` in `tiny_lm.py` + Real term: not a transformer block + This is just a baseline neural language model for learning the training loop. + +- `query`, `key`, and `value` in `tiny_transformer_lm.py` + Real term: self-attention mechanism + This is the core transformer idea that lets tokens weigh other tokens dynamically. + +- `multi-head attention` in `tiny_modern_lm.py` + Real term: multi-head self-attention + Real models use multiple heads so different relationships can be tracked in parallel. + +- `feed-forward MLP` + Real term: transformer feed-forward network, often called FFN or MLP block + This is the per-position transformation that sits alongside attention. + +- `layer norm` + Real term: LayerNorm or RMSNorm + Modern models rely on this for stable deep training. + +- `residual connection` + Real term: residual / skip connection + This helps preserve information and makes deep optimization work better. + +- `stacking many blocks` + Real term: transformer depth + A real LLM is many transformer blocks stacked on top of each other. + +- `training on corpus.txt` + Real term: pretraining corpus + A real LLM uses a vastly larger and more varied dataset. + +- `SGD in the toy scripts` + Real term: optimizer + Real models usually use AdamW or similar optimizers, not plain SGD. + +- `sample generation` + Real term: inference / decoding + Real systems add practical features such as KV cache, batching, and quantization. + +- `missing instruction tuning` + Real term: post-training / supervised fine-tuning + This is the stage where a pretrained model is taught to respond usefully to prompts. + +- `missing preference optimization` + Real term: RLHF, DPO, or related alignment methods + This is where models are shaped to better match human preferences and policy goals. + +The rough real-world sequence is: + +1. Build a tokenizer and vocabulary. +2. Define a transformer architecture. +3. Pretrain it on next-token prediction. +4. Fine-tune or instruction-tune it for useful interaction. +5. Apply preference and safety training. +6. Optimize inference and deploy it. diff --git a/corpus.txt b/corpus.txt @@ -0,0 +1,25 @@ +language models predict the next token. +language models start as code and random numbers. +language models learn by repeated correction. + +the code defines the model. +the weights start random. +training changes the weights. +prediction creates error. +error updates the weights. + +humans write the tokenizer. +humans write the training loop. +humans choose the architecture. +the model learns the numerical patterns. + +read tokens. +predict next token. +measure error. +adjust weights. +repeat. + +small models show the same loop in a simpler form. +large models scale the same loop up. +the structure is chosen by people. +the behavior is shaped by training. diff --git a/tiny_lm.py b/tiny_lm.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +""" +Tiny character-level neural language model in plain Python. + +This is intentionally small and readable. It is not a transformer and not a real +LLM. It exists to show the basic mechanics of next-token training: + +- tokenize text +- embed tokens +- run a forward pass +- compute softmax loss +- backpropagate gradients +- update weights +- sample new text +""" + +from __future__ import annotations + +import argparse +import math +import random +from dataclasses import dataclass +from pathlib import Path + + +def zeros(length: int) -> list[float]: + return [0.0 for _ in range(length)] + + +def random_vector(length: int, scale: float, rng: random.Random) -> list[float]: + return [rng.uniform(-scale, scale) for _ in range(length)] + + +def random_matrix(rows: int, cols: int, scale: float, rng: random.Random) -> list[list[float]]: + return [random_vector(cols, scale, rng) for _ in range(rows)] + + +def softmax(logits: list[float]) -> list[float]: + peak = max(logits) + exps = [math.exp(value - peak) for value in logits] + total = sum(exps) + return [value / total for value in exps] + + +@dataclass +class Batch: + context_ids: list[int] + target_id: int + + +class CharTokenizer: + def __init__(self, text: str) -> None: + chars = sorted(set(text)) + self.stoi = {char: index for index, char in enumerate(chars)} + self.itos = {index: char for char, index in self.stoi.items()} + + @property + def vocab_size(self) -> int: + return len(self.stoi) + + def encode(self, text: str) -> list[int]: + return [self.stoi[char] for char in text] + + def decode(self, token_ids: list[int]) -> str: + return "".join(self.itos[token_id] for token_id in token_ids) + + +class TinyLanguageModel: + def __init__( + self, + vocab_size: int, + context_size: int, + embed_dim: int, + hidden_dim: int, + seed: int, + ) -> None: + rng = random.Random(seed) + self.vocab_size = vocab_size + self.context_size = context_size + self.embed_dim = embed_dim + self.hidden_dim = hidden_dim + self.input_dim = context_size * embed_dim + + self.token_embed = random_matrix(vocab_size, embed_dim, 0.08, rng) + self.w1 = random_matrix(self.input_dim, hidden_dim, 0.08, rng) + self.b1 = zeros(hidden_dim) + self.w2 = random_matrix(hidden_dim, vocab_size, 0.08, rng) + self.b2 = zeros(vocab_size) + + def forward(self, context_ids: list[int]) -> tuple[list[float], dict[str, list[float] | list[int]]]: + combined = zeros(self.input_dim) + for position, token_id in enumerate(context_ids): + token_vector = self.token_embed[token_id] + for dim in range(self.embed_dim): + combined[position * self.embed_dim + dim] = token_vector[dim] + + hidden_pre = zeros(self.hidden_dim) + for hidden_idx in range(self.hidden_dim): + total = self.b1[hidden_idx] + for dim in range(self.input_dim): + total += combined[dim] * self.w1[dim][hidden_idx] + hidden_pre[hidden_idx] = total + + hidden = [math.tanh(value) for value in hidden_pre] + + logits = zeros(self.vocab_size) + for vocab_idx in range(self.vocab_size): + total = self.b2[vocab_idx] + for hidden_idx in range(self.hidden_dim): + total += hidden[hidden_idx] * self.w2[hidden_idx][vocab_idx] + logits[vocab_idx] = total + + cache = { + "context_ids": context_ids, + "combined": combined, + "hidden": hidden, + } + return logits, cache + + def train_step(self, batch: Batch, learning_rate: float) -> float: + logits, cache = self.forward(batch.context_ids) + probs = softmax(logits) + loss = -math.log(probs[batch.target_id] + 1e-12) + + dlogits = probs[:] + dlogits[batch.target_id] -= 1.0 + + hidden = cache["hidden"] + combined = cache["combined"] + + dw2 = [zeros(self.vocab_size) for _ in range(self.hidden_dim)] + db2 = dlogits[:] + for hidden_idx in range(self.hidden_dim): + for vocab_idx in range(self.vocab_size): + dw2[hidden_idx][vocab_idx] = hidden[hidden_idx] * dlogits[vocab_idx] + + dhidden = zeros(self.hidden_dim) + for hidden_idx in range(self.hidden_dim): + total = 0.0 + for vocab_idx in range(self.vocab_size): + total += self.w2[hidden_idx][vocab_idx] * dlogits[vocab_idx] + dhidden[hidden_idx] = total + + dhidden_pre = zeros(self.hidden_dim) + for hidden_idx in range(self.hidden_dim): + dhidden_pre[hidden_idx] = dhidden[hidden_idx] * (1.0 - hidden[hidden_idx] ** 2) + + dw1 = [zeros(self.hidden_dim) for _ in range(self.input_dim)] + db1 = dhidden_pre[:] + for dim in range(self.input_dim): + for hidden_idx in range(self.hidden_dim): + dw1[dim][hidden_idx] = combined[dim] * dhidden_pre[hidden_idx] + + dcombined = zeros(self.input_dim) + for dim in range(self.input_dim): + total = 0.0 + for hidden_idx in range(self.hidden_dim): + total += self.w1[dim][hidden_idx] * dhidden_pre[hidden_idx] + dcombined[dim] = total + + for position, token_id in enumerate(batch.context_ids): + for dim in range(self.embed_dim): + gradient = dcombined[position * self.embed_dim + dim] + self.token_embed[token_id][dim] -= learning_rate * gradient + + for dim in range(self.input_dim): + for hidden_idx in range(self.hidden_dim): + self.w1[dim][hidden_idx] -= learning_rate * dw1[dim][hidden_idx] + for hidden_idx in range(self.hidden_dim): + self.b1[hidden_idx] -= learning_rate * db1[hidden_idx] + + for hidden_idx in range(self.hidden_dim): + for vocab_idx in range(self.vocab_size): + self.w2[hidden_idx][vocab_idx] -= learning_rate * dw2[hidden_idx][vocab_idx] + for vocab_idx in range(self.vocab_size): + self.b2[vocab_idx] -= learning_rate * db2[vocab_idx] + + return loss + + def sample_next_token(self, context_ids: list[int], rng: random.Random, temperature: float) -> int: + logits, _ = self.forward(context_ids) + scaled = [value / max(temperature, 1e-6) for value in logits] + probs = softmax(scaled) + + threshold = rng.random() + running = 0.0 + for index, prob in enumerate(probs): + running += prob + if threshold <= running: + return index + return len(probs) - 1 + + +def build_batches(token_ids: list[int], context_size: int) -> list[Batch]: + batches: list[Batch] = [] + for index in range(len(token_ids) - context_size): + batches.append( + Batch( + context_ids=token_ids[index : index + context_size], + target_id=token_ids[index + context_size], + ) + ) + return batches + + +def generate_text( + model: TinyLanguageModel, + tokenizer: CharTokenizer, + prompt: str, + sample_length: int, + rng: random.Random, + temperature: float, +) -> str: + if not prompt: + prompt = "language " + + if any(char not in tokenizer.stoi for char in prompt): + known = "".join(sorted(tokenizer.stoi.keys())) + raise ValueError(f"Prompt contains characters outside the training set. Known chars: {known!r}") + + token_ids = tokenizer.encode(prompt) + if len(token_ids) < model.context_size: + token_ids = [token_ids[0]] * (model.context_size - len(token_ids)) + token_ids + + generated = token_ids[:] + for _ in range(sample_length): + context_ids = generated[-model.context_size :] + next_token = model.sample_next_token(context_ids, rng, temperature) + generated.append(next_token) + + return tokenizer.decode(generated) + + +def load_corpus(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Train a tiny next-character language model.") + parser.add_argument("--steps", type=int, default=3000, help="Number of SGD steps.") + parser.add_argument("--report-every", type=int, default=500, help="Progress report interval.") + parser.add_argument("--learning-rate", type=float, default=0.08, help="SGD learning rate.") + parser.add_argument("--context-size", type=int, default=12, help="Context window length.") + parser.add_argument("--embed-dim", type=int, default=24, help="Embedding size.") + parser.add_argument("--hidden-dim", type=int, default=48, help="Hidden layer size.") + parser.add_argument("--sample-length", type=int, default=220, help="Characters to generate.") + parser.add_argument("--temperature", type=float, default=0.9, help="Sampling temperature.") + parser.add_argument("--seed", type=int, default=7, help="Random seed.") + parser.add_argument("--prompt", default="language models ", help="Initial prompt for generation.") + parser.add_argument( + "--repeat-corpus", + type=int, + default=8, + help="Repeat the corpus this many times to make the tiny demo learn faster.", + ) + args = parser.parse_args() + + project_dir = Path(__file__).resolve().parent + corpus_text = load_corpus(project_dir / "corpus.txt") + corpus_text = corpus_text * max(args.repeat_corpus, 1) + + tokenizer = CharTokenizer(corpus_text) + token_ids = tokenizer.encode(corpus_text) + batches = build_batches(token_ids, args.context_size) + if not batches: + raise ValueError("Corpus is too small for the requested context size.") + + model = TinyLanguageModel( + vocab_size=tokenizer.vocab_size, + context_size=args.context_size, + embed_dim=args.embed_dim, + hidden_dim=args.hidden_dim, + seed=args.seed, + ) + + rng = random.Random(args.seed) + running_loss = 0.0 + + print(f"vocab size: {tokenizer.vocab_size}") + print(f"training samples: {len(batches)}") + print("training...") + + for step in range(1, args.steps + 1): + batch = batches[rng.randrange(len(batches))] + loss = model.train_step(batch, args.learning_rate) + running_loss += loss + + if step % args.report_every == 0 or step == 1: + avg_loss = running_loss / min(step, args.report_every) + print(f"step {step:5d} | avg loss {avg_loss:.4f}") + running_loss = 0.0 + + print("\n--- sample ---\n") + sample = generate_text( + model=model, + tokenizer=tokenizer, + prompt=args.prompt, + sample_length=args.sample_length, + rng=rng, + temperature=args.temperature, + ) + print(sample) + + +if __name__ == "__main__": + main() diff --git a/tiny_modern_lm.py b/tiny_modern_lm.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +""" +Tiny forward-only demo of a more modern transformer-style language model block. + +This file is meant to show architecture, not serious training. It includes: + +- token embeddings +- positional embeddings +- layer normalization +- causal multi-head self-attention +- residual connections +- feed-forward network +- output projection to logits + +Why forward-only? +Because implementing a faithful transformer block *and* all of its backpropagation +in plain standard-library Python would swamp the core ideas in gradient code. +The earlier demos show training mechanics. This file shows the modern block shape. +""" + +from __future__ import annotations + +import argparse +import math +import random +from pathlib import Path + + +def zeros(length: int) -> list[float]: + return [0.0 for _ in range(length)] + + +def random_vector(length: int, scale: float, rng: random.Random) -> list[float]: + return [rng.uniform(-scale, scale) for _ in range(length)] + + +def random_matrix(rows: int, cols: int, scale: float, rng: random.Random) -> list[list[float]]: + return [random_vector(cols, scale, rng) for _ in range(rows)] + + +def add(a: list[float], b: list[float]) -> list[float]: + return [x + y for x, y in zip(a, b)] + + +def softmax(logits: list[float]) -> list[float]: + peak = max(logits) + exps = [math.exp(value - peak) for value in logits] + total = sum(exps) + return [value / total for value in exps] + + +def gelu(values: list[float]) -> list[float]: + out = [] + for x in values: + out.append(0.5 * x * (1.0 + math.tanh(math.sqrt(2.0 / math.pi) * (x + 0.044715 * x ** 3)))) + return out + + +def matvec(vector: list[float], matrix: list[list[float]]) -> list[float]: + cols = len(matrix[0]) + out = zeros(cols) + for col in range(cols): + total = 0.0 + for row in range(len(vector)): + total += vector[row] * matrix[row][col] + out[col] = total + return out + + +def layer_norm(vector: list[float], gamma: list[float], beta: list[float], eps: float = 1e-5) -> list[float]: + mean = sum(vector) / len(vector) + variance = sum((value - mean) ** 2 for value in vector) / len(vector) + denom = math.sqrt(variance + eps) + return [((value - mean) / denom) * gamma[i] + beta[i] for i, value in enumerate(vector)] + + +class CharTokenizer: + def __init__(self, text: str) -> None: + chars = sorted(set(text)) + self.stoi = {char: index for index, char in enumerate(chars)} + self.itos = {index: char for char, index in self.stoi.items()} + + @property + def vocab_size(self) -> int: + return len(self.stoi) + + def encode(self, text: str) -> list[int]: + return [self.stoi[char] for char in text] + + def decode(self, token_ids: list[int]) -> str: + return "".join(self.itos[token_id] for token_id in token_ids) + + +class TinyModernTransformerLM: + def __init__(self, vocab_size: int, context_size: int, embed_dim: int, num_heads: int, seed: int) -> None: + if embed_dim % num_heads != 0: + raise ValueError("embed_dim must be divisible by num_heads") + + rng = random.Random(seed) + self.vocab_size = vocab_size + self.context_size = context_size + self.embed_dim = embed_dim + self.num_heads = num_heads + self.head_dim = embed_dim // num_heads + + self.token_embed = random_matrix(vocab_size, embed_dim, 0.08, rng) + self.pos_embed = random_matrix(context_size, embed_dim, 0.08, rng) + + self.ln1_gamma = [1.0] * embed_dim + self.ln1_beta = [0.0] * embed_dim + self.ln2_gamma = [1.0] * embed_dim + self.ln2_beta = [0.0] * embed_dim + + self.wq = random_matrix(embed_dim, embed_dim, 0.08, rng) + self.wk = random_matrix(embed_dim, embed_dim, 0.08, rng) + self.wv = random_matrix(embed_dim, embed_dim, 0.08, rng) + self.wo = random_matrix(embed_dim, embed_dim, 0.08, rng) + + ff_dim = embed_dim * 4 + self.w1 = random_matrix(embed_dim, ff_dim, 0.08, rng) + self.b1 = zeros(ff_dim) + self.w2 = random_matrix(ff_dim, embed_dim, 0.08, rng) + self.b2 = zeros(embed_dim) + + self.lm_head = random_matrix(embed_dim, vocab_size, 0.08, rng) + self.lm_bias = zeros(vocab_size) + + def causal_attention(self, x_vectors: list[list[float]]) -> tuple[list[list[float]], list[list[list[float]]]]: + q_all = [matvec(x, self.wq) for x in x_vectors] + k_all = [matvec(x, self.wk) for x in x_vectors] + v_all = [matvec(x, self.wv) for x in x_vectors] + scale = math.sqrt(self.head_dim) + + head_weights: list[list[list[float]]] = [] + head_outputs: list[list[list[float]]] = [] + + for head in range(self.num_heads): + start = head * self.head_dim + stop = start + self.head_dim + weights_for_head: list[list[float]] = [] + outputs_for_head: list[list[float]] = [] + + for target_pos in range(self.context_size): + scores = [] + for source_pos in range(self.context_size): + if source_pos > target_pos: + scores.append(-1e9) + continue + dot = 0.0 + for dim in range(start, stop): + dot += q_all[target_pos][dim] * k_all[source_pos][dim] + scores.append(dot / scale) + + attn = softmax(scores) + weights_for_head.append(attn) + + out = zeros(self.head_dim) + for source_pos in range(self.context_size): + for local_dim, dim in enumerate(range(start, stop)): + out[local_dim] += attn[source_pos] * v_all[source_pos][dim] + outputs_for_head.append(out) + + head_weights.append(weights_for_head) + head_outputs.append(outputs_for_head) + + combined_outputs: list[list[float]] = [] + for pos in range(self.context_size): + merged = [] + for head in range(self.num_heads): + merged.extend(head_outputs[head][pos]) + combined_outputs.append(matvec(merged, self.wo)) + + return combined_outputs, head_weights + + def feed_forward(self, vector: list[float]) -> list[float]: + hidden = matvec(vector, self.w1) + hidden = [hidden[i] + self.b1[i] for i in range(len(hidden))] + hidden = gelu(hidden) + out = matvec(hidden, self.w2) + return [out[i] + self.b2[i] for i in range(len(out))] + + def forward(self, context_ids: list[int]) -> tuple[list[float], dict[str, object]]: + x = [] + for pos, token_id in enumerate(context_ids): + x.append(add(self.token_embed[token_id], self.pos_embed[pos])) + + ln1 = [layer_norm(vec, self.ln1_gamma, self.ln1_beta) for vec in x] + attn_out, head_weights = self.causal_attention(ln1) + x = [add(x[pos], attn_out[pos]) for pos in range(self.context_size)] + + ln2 = [layer_norm(vec, self.ln2_gamma, self.ln2_beta) for vec in x] + ff_out = [self.feed_forward(vec) for vec in ln2] + x = [add(x[pos], ff_out[pos]) for pos in range(self.context_size)] + + final_state = x[-1] + logits = matvec(final_state, self.lm_head) + logits = [logits[i] + self.lm_bias[i] for i in range(self.vocab_size)] + + cache = { + "head_weights": head_weights, + "final_state": final_state, + } + return logits, cache + + +def top_k_indices(values: list[float], k: int) -> list[int]: + return sorted(range(len(values)), key=lambda index: values[index], reverse=True)[:k] + + +def load_corpus(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Inspect a tiny modern transformer-style LM block.") + parser.add_argument("--context-size", type=int, default=12, help="Context window length.") + parser.add_argument("--embed-dim", type=int, default=24, help="Embedding size.") + parser.add_argument("--num-heads", type=int, default=3, help="Number of attention heads.") + parser.add_argument("--seed", type=int, default=7, help="Random seed.") + parser.add_argument("--prompt", default="language mod", help="Prompt to inspect.") + parser.add_argument("--top-k", type=int, default=8, help="How many next-token candidates to print.") + args = parser.parse_args() + + project_dir = Path(__file__).resolve().parent + corpus_text = load_corpus(project_dir / "corpus.txt") + tokenizer = CharTokenizer(corpus_text) + + if any(char not in tokenizer.stoi for char in args.prompt): + known = "".join(sorted(tokenizer.stoi.keys())) + raise ValueError(f"Prompt contains characters outside the training set. Known chars: {known!r}") + + token_ids = tokenizer.encode(args.prompt) + if len(token_ids) < args.context_size: + token_ids = [token_ids[0]] * (args.context_size - len(token_ids)) + token_ids + context_ids = token_ids[-args.context_size :] + + model = TinyModernTransformerLM( + vocab_size=tokenizer.vocab_size, + context_size=args.context_size, + embed_dim=args.embed_dim, + num_heads=args.num_heads, + seed=args.seed, + ) + + logits, cache = model.forward(context_ids) + probs = softmax(logits) + top = top_k_indices(probs, args.top_k) + + print("tiny modern transformer-style LM block") + print(f"context: {tokenizer.decode(context_ids)!r}") + print(f"vocab size: {tokenizer.vocab_size}") + print(f"embed dim: {args.embed_dim}") + print(f"heads: {args.num_heads}") + + print("\n--- top next-token candidates ---\n") + for index in top: + char = tokenizer.itos[index] + label = "\\n" if char == "\n" else char + print(f"{label!r}: {probs[index]:.4f}") + + head_weights = cache["head_weights"] + print("\n--- attention from final position ---\n") + context_text = tokenizer.decode(context_ids) + for head_index, weights_for_head in enumerate(head_weights): + final_weights = weights_for_head[-1] + print(f"head {head_index}:") + for pos, weight in enumerate(final_weights): + char = context_text[pos] + label = "\\n" if char == "\n" else char + print(f" pos {pos:2d} | char {label!r} | weight {weight:.4f}") + + +if __name__ == "__main__": + main() diff --git a/tiny_transformer_lm.py b/tiny_transformer_lm.py @@ -0,0 +1,385 @@ +#!/usr/bin/env python3 +""" +Tiny character-level language model with a minimal causal self-attention block. + +This is still far from a real transformer, but it shows the key structural leap +from the plain MLP demo: + +- each context position gets its own representation +- the final position forms a query +- all earlier positions provide keys and values +- attention weights decide what matters dynamically + +The model predicts the next character from the attended summary of the context. +""" + +from __future__ import annotations + +import argparse +import math +import random +from dataclasses import dataclass +from pathlib import Path + + +def zeros(length: int) -> list[float]: + return [0.0 for _ in range(length)] + + +def random_vector(length: int, scale: float, rng: random.Random) -> list[float]: + return [rng.uniform(-scale, scale) for _ in range(length)] + + +def random_matrix(rows: int, cols: int, scale: float, rng: random.Random) -> list[list[float]]: + return [random_vector(cols, scale, rng) for _ in range(rows)] + + +def softmax(logits: list[float]) -> list[float]: + peak = max(logits) + exps = [math.exp(value - peak) for value in logits] + total = sum(exps) + return [value / total for value in exps] + + +@dataclass +class Batch: + context_ids: list[int] + target_id: int + + +class CharTokenizer: + def __init__(self, text: str) -> None: + chars = sorted(set(text)) + self.stoi = {char: index for index, char in enumerate(chars)} + self.itos = {index: char for char, index in self.stoi.items()} + + @property + def vocab_size(self) -> int: + return len(self.stoi) + + def encode(self, text: str) -> list[int]: + return [self.stoi[char] for char in text] + + def decode(self, token_ids: list[int]) -> str: + return "".join(self.itos[token_id] for token_id in token_ids) + + +class TinyAttentionLanguageModel: + def __init__(self, vocab_size: int, context_size: int, embed_dim: int, seed: int) -> None: + rng = random.Random(seed) + self.vocab_size = vocab_size + self.context_size = context_size + self.embed_dim = embed_dim + self.scale = math.sqrt(embed_dim) + + self.token_embed = random_matrix(vocab_size, embed_dim, 0.08, rng) + self.pos_embed = random_matrix(context_size, embed_dim, 0.08, rng) + + self.wq = random_matrix(embed_dim, embed_dim, 0.08, rng) + self.wk = random_matrix(embed_dim, embed_dim, 0.08, rng) + self.wv = random_matrix(embed_dim, embed_dim, 0.08, rng) + self.wo = random_matrix(embed_dim, vocab_size, 0.08, rng) + self.bo = zeros(vocab_size) + + def matvec(self, vector: list[float], matrix: list[list[float]]) -> list[float]: + cols = len(matrix[0]) + out = zeros(cols) + for col in range(cols): + total = 0.0 + for row in range(len(vector)): + total += vector[row] * matrix[row][col] + out[col] = total + return out + + def forward(self, context_ids: list[int]) -> tuple[list[float], dict[str, object]]: + x_vectors: list[list[float]] = [] + q_vectors: list[list[float]] = [] + k_vectors: list[list[float]] = [] + v_vectors: list[list[float]] = [] + + for position, token_id in enumerate(context_ids): + x = [ + self.token_embed[token_id][dim] + self.pos_embed[position][dim] + for dim in range(self.embed_dim) + ] + x_vectors.append(x) + q_vectors.append(self.matvec(x, self.wq)) + k_vectors.append(self.matvec(x, self.wk)) + v_vectors.append(self.matvec(x, self.wv)) + + last_index = len(context_ids) - 1 + q_last = q_vectors[last_index] + + scores = zeros(len(context_ids)) + for index in range(len(context_ids)): + dot = 0.0 + for dim in range(self.embed_dim): + dot += q_last[dim] * k_vectors[index][dim] + scores[index] = dot / self.scale + + attn = softmax(scores) + + attended = zeros(self.embed_dim) + for index in range(len(context_ids)): + for dim in range(self.embed_dim): + attended[dim] += attn[index] * v_vectors[index][dim] + + logits = zeros(self.vocab_size) + for vocab_idx in range(self.vocab_size): + total = self.bo[vocab_idx] + for dim in range(self.embed_dim): + total += attended[dim] * self.wo[dim][vocab_idx] + logits[vocab_idx] = total + + cache: dict[str, object] = { + "context_ids": context_ids, + "x_vectors": x_vectors, + "q_vectors": q_vectors, + "k_vectors": k_vectors, + "v_vectors": v_vectors, + "scores": scores, + "attn": attn, + "attended": attended, + } + return logits, cache + + def train_step(self, batch: Batch, learning_rate: float) -> float: + logits, cache = self.forward(batch.context_ids) + probs = softmax(logits) + loss = -math.log(probs[batch.target_id] + 1e-12) + + dlogits = probs[:] + dlogits[batch.target_id] -= 1.0 + + attended = cache["attended"] + attn = cache["attn"] + x_vectors = cache["x_vectors"] + q_vectors = cache["q_vectors"] + k_vectors = cache["k_vectors"] + v_vectors = cache["v_vectors"] + + dwo = [zeros(self.vocab_size) for _ in range(self.embed_dim)] + dbo = dlogits[:] + for dim in range(self.embed_dim): + for vocab_idx in range(self.vocab_size): + dwo[dim][vocab_idx] = attended[dim] * dlogits[vocab_idx] + + dattended = zeros(self.embed_dim) + for dim in range(self.embed_dim): + total = 0.0 + for vocab_idx in range(self.vocab_size): + total += self.wo[dim][vocab_idx] * dlogits[vocab_idx] + dattended[dim] = total + + dattn = zeros(self.context_size) + dv_vectors = [zeros(self.embed_dim) for _ in range(self.context_size)] + for pos in range(self.context_size): + dot = 0.0 + for dim in range(self.embed_dim): + dot += dattended[dim] * v_vectors[pos][dim] + dv_vectors[pos][dim] += attn[pos] * dattended[dim] + dattn[pos] = dot + + weighted = 0.0 + for pos in range(self.context_size): + weighted += attn[pos] * dattn[pos] + + dscores = zeros(self.context_size) + for pos in range(self.context_size): + dscores[pos] = attn[pos] * (dattn[pos] - weighted) + + dq_last = zeros(self.embed_dim) + dk_vectors = [zeros(self.embed_dim) for _ in range(self.context_size)] + for pos in range(self.context_size): + for dim in range(self.embed_dim): + dq_last[dim] += dscores[pos] * k_vectors[pos][dim] / self.scale + dk_vectors[pos][dim] += dscores[pos] * q_vectors[self.context_size - 1][dim] / self.scale + + dwq = [zeros(self.embed_dim) for _ in range(self.embed_dim)] + dwk = [zeros(self.embed_dim) for _ in range(self.embed_dim)] + dwv = [zeros(self.embed_dim) for _ in range(self.embed_dim)] + dx_vectors = [zeros(self.embed_dim) for _ in range(self.context_size)] + + last_x = x_vectors[self.context_size - 1] + for in_dim in range(self.embed_dim): + for out_dim in range(self.embed_dim): + dwq[in_dim][out_dim] += last_x[in_dim] * dq_last[out_dim] + dx_vectors[self.context_size - 1][in_dim] += self.wq[in_dim][out_dim] * dq_last[out_dim] + + for pos in range(self.context_size): + x = x_vectors[pos] + for in_dim in range(self.embed_dim): + for out_dim in range(self.embed_dim): + dwk[in_dim][out_dim] += x[in_dim] * dk_vectors[pos][out_dim] + dwv[in_dim][out_dim] += x[in_dim] * dv_vectors[pos][out_dim] + dx_vectors[pos][in_dim] += self.wk[in_dim][out_dim] * dk_vectors[pos][out_dim] + dx_vectors[pos][in_dim] += self.wv[in_dim][out_dim] * dv_vectors[pos][out_dim] + + for pos, token_id in enumerate(batch.context_ids): + for dim in range(self.embed_dim): + gradient = dx_vectors[pos][dim] + self.token_embed[token_id][dim] -= learning_rate * gradient + self.pos_embed[pos][dim] -= learning_rate * gradient + + for in_dim in range(self.embed_dim): + for out_dim in range(self.embed_dim): + self.wq[in_dim][out_dim] -= learning_rate * dwq[in_dim][out_dim] + self.wk[in_dim][out_dim] -= learning_rate * dwk[in_dim][out_dim] + self.wv[in_dim][out_dim] -= learning_rate * dwv[in_dim][out_dim] + + for dim in range(self.embed_dim): + for vocab_idx in range(self.vocab_size): + self.wo[dim][vocab_idx] -= learning_rate * dwo[dim][vocab_idx] + for vocab_idx in range(self.vocab_size): + self.bo[vocab_idx] -= learning_rate * dbo[vocab_idx] + + return loss + + def sample_next_token(self, context_ids: list[int], rng: random.Random, temperature: float) -> int: + logits, _ = self.forward(context_ids) + scaled = [value / max(temperature, 1e-6) for value in logits] + probs = softmax(scaled) + + threshold = rng.random() + running = 0.0 + for index, prob in enumerate(probs): + running += prob + if threshold <= running: + return index + return len(probs) - 1 + + def attention_weights(self, context_ids: list[int]) -> list[float]: + _, cache = self.forward(context_ids) + return list(cache["attn"]) + + +def build_batches(token_ids: list[int], context_size: int) -> list[Batch]: + batches: list[Batch] = [] + for index in range(len(token_ids) - context_size): + batches.append( + Batch( + context_ids=token_ids[index : index + context_size], + target_id=token_ids[index + context_size], + ) + ) + return batches + + +def generate_text( + model: TinyAttentionLanguageModel, + tokenizer: CharTokenizer, + prompt: str, + sample_length: int, + rng: random.Random, + temperature: float, +) -> str: + if not prompt: + prompt = "language " + + if any(char not in tokenizer.stoi for char in prompt): + known = "".join(sorted(tokenizer.stoi.keys())) + raise ValueError(f"Prompt contains characters outside the training set. Known chars: {known!r}") + + token_ids = tokenizer.encode(prompt) + if len(token_ids) < model.context_size: + token_ids = [token_ids[0]] * (model.context_size - len(token_ids)) + token_ids + + generated = token_ids[:] + for _ in range(sample_length): + context_ids = generated[-model.context_size :] + next_token = model.sample_next_token(context_ids, rng, temperature) + generated.append(next_token) + + return tokenizer.decode(generated) + + +def load_corpus(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Train a tiny self-attention language model.") + parser.add_argument("--steps", type=int, default=2500, help="Number of SGD steps.") + parser.add_argument("--report-every", type=int, default=500, help="Progress report interval.") + parser.add_argument("--learning-rate", type=float, default=0.03, help="SGD learning rate.") + parser.add_argument("--context-size", type=int, default=8, help="Context window length.") + parser.add_argument("--embed-dim", type=int, default=16, help="Embedding size.") + parser.add_argument("--sample-length", type=int, default=220, help="Characters to generate.") + parser.add_argument("--temperature", type=float, default=0.8, help="Sampling temperature.") + parser.add_argument("--seed", type=int, default=7, help="Random seed.") + parser.add_argument("--prompt", default="language models ", help="Initial prompt for generation.") + parser.add_argument( + "--repeat-corpus", + type=int, + default=12, + help="Repeat the corpus this many times to make the tiny demo learn faster.", + ) + parser.add_argument( + "--show-attention", + action="store_true", + help="Print attention weights for the final prompt context after training.", + ) + args = parser.parse_args() + + project_dir = Path(__file__).resolve().parent + corpus_text = load_corpus(project_dir / "corpus.txt") + corpus_text = corpus_text * max(args.repeat_corpus, 1) + + tokenizer = CharTokenizer(corpus_text) + token_ids = tokenizer.encode(corpus_text) + batches = build_batches(token_ids, args.context_size) + if not batches: + raise ValueError("Corpus is too small for the requested context size.") + + model = TinyAttentionLanguageModel( + vocab_size=tokenizer.vocab_size, + context_size=args.context_size, + embed_dim=args.embed_dim, + seed=args.seed, + ) + + rng = random.Random(args.seed) + running_loss = 0.0 + + print(f"vocab size: {tokenizer.vocab_size}") + print(f"training samples: {len(batches)}") + print("training...") + + for step in range(1, args.steps + 1): + batch = batches[rng.randrange(len(batches))] + loss = model.train_step(batch, args.learning_rate) + running_loss += loss + + if step % args.report_every == 0 or step == 1: + avg_loss = running_loss / min(step, args.report_every) + print(f"step {step:5d} | avg loss {avg_loss:.4f}") + running_loss = 0.0 + + print("\n--- sample ---\n") + sample = generate_text( + model=model, + tokenizer=tokenizer, + prompt=args.prompt, + sample_length=args.sample_length, + rng=rng, + temperature=args.temperature, + ) + print(sample) + + if args.show_attention: + prompt_ids = tokenizer.encode(args.prompt) + if len(prompt_ids) < model.context_size: + prompt_ids = [prompt_ids[0]] * (model.context_size - len(prompt_ids)) + prompt_ids + context_ids = prompt_ids[-model.context_size :] + weights = model.attention_weights(context_ids) + + print("\n--- attention on final prompt context ---\n") + context_text = tokenizer.decode(context_ids) + for index, weight in enumerate(weights): + char = context_text[index] + label = "\\n" if char == "\n" else char + print(f"pos {index:2d} | char {label!r} | weight {weight:.4f}") + + +if __name__ == "__main__": + main()