{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# شبکه‌های مولد\n", "\n", "شبکه‌های عصبی بازگشتی (RNNs) و انواع سلول‌های دروازه‌دار آن‌ها مانند سلول‌های حافظه کوتاه‌مدت بلند (LSTMs) و واحدهای بازگشتی دروازه‌دار (GRUs) مکانیزمی برای مدل‌سازی زبان فراهم کردند، یعنی آن‌ها می‌توانند ترتیب کلمات را یاد بگیرند و پیش‌بینی‌هایی برای کلمه بعدی در یک دنباله ارائه دهند. این قابلیت به ما اجازه می‌دهد تا از RNNها برای **وظایف مولد** استفاده کنیم، مانند تولید متن معمولی، ترجمه ماشینی، و حتی توضیح‌نویسی تصاویر.\n", "\n", "در معماری RNN که در واحد قبلی مورد بحث قرار گرفت، هر واحد RNN حالت مخفی بعدی را به عنوان خروجی تولید می‌کرد. با این حال، ما می‌توانیم خروجی دیگری به هر واحد بازگشتی اضافه کنیم که به ما اجازه می‌دهد یک **دنباله** (که طول آن برابر با دنباله اصلی است) تولید کنیم. علاوه بر این، می‌توانیم از واحدهای RNN استفاده کنیم که در هر مرحله ورودی دریافت نمی‌کنند و فقط یک بردار حالت اولیه می‌گیرند و سپس یک دنباله از خروجی‌ها تولید می‌کنند.\n", "\n", "در این دفترچه، ما بر مدل‌های مولد ساده تمرکز خواهیم کرد که به ما کمک می‌کنند متن تولید کنیم. برای سادگی، بیایید یک **شبکه سطح کاراکتر** بسازیم که متن را حرف به حرف تولید کند. در طول آموزش، باید یک متن مرجع را بگیریم و آن را به دنباله‌های حرف تقسیم کنیم.\n" ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Loading dataset...\n", "Building vocab...\n" ] } ], "source": [ "import torch\n", "import torchtext\n", "import numpy as np\n", "from torchnlp import *\n", "train_dataset,test_dataset,classes,vocab = load_dataset()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## ساخت واژگان کاراکتر\n", "\n", "برای ساخت شبکه تولیدی در سطح کاراکتر، باید متن را به جای کلمات به کاراکترهای جداگانه تقسیم کنیم. این کار با تعریف یک توکنایزر متفاوت قابل انجام است:\n" ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Vocabulary size = 82\n", "Encoding of 'a' is 1\n", "Character with code 13 is c\n" ] } ], "source": [ "def char_tokenizer(words):\n", " return list(words) #[word for word in words]\n", "\n", "counter = collections.Counter()\n", "for (label, line) in train_dataset:\n", " counter.update(char_tokenizer(line))\n", "vocab = torchtext.vocab.vocab(counter)\n", "\n", "vocab_size = len(vocab)\n", "print(f\"Vocabulary size = {vocab_size}\")\n", "print(f\"Encoding of 'a' is {vocab.get_stoi()['a']}\")\n", "print(f\"Character with code 13 is {vocab.get_itos()[13]}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "بیایید مثالی ببینیم از اینکه چگونه می‌توانیم متن را از مجموعه داده خود رمزگذاری کنیم:\n" ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "tensor([ 0, 1, 2, 2, 3, 4, 5, 6, 3, 7, 8, 1, 9, 10, 3, 11, 2, 1,\n", " 12, 3, 7, 1, 13, 14, 3, 15, 16, 5, 17, 3, 5, 18, 8, 3, 7, 2,\n", " 1, 13, 14, 3, 19, 20, 8, 21, 5, 8, 9, 10, 22, 3, 20, 8, 21, 5,\n", " 8, 9, 10, 3, 23, 3, 4, 18, 17, 9, 5, 23, 10, 8, 2, 2, 8, 9,\n", " 10, 24, 3, 0, 1, 2, 2, 3, 4, 5, 9, 8, 8, 5, 25, 10, 3, 26,\n", " 12, 27, 16, 26, 2, 27, 16, 28, 29, 30, 1, 16, 26, 3, 17, 31, 3, 21,\n", " 2, 5, 9, 1, 23, 13, 32, 16, 27, 13, 10, 24, 3, 1, 9, 8, 3, 10,\n", " 8, 8, 27, 16, 28, 3, 28, 9, 8, 8, 16, 3, 1, 28, 1, 27, 16, 6])" ] }, "execution_count": 3, "metadata": {}, "output_type": "execute_result" } ], "source": [ "def enc(x):\n", " return torch.LongTensor(encode(x,voc=vocab,tokenizer=char_tokenizer))\n", "\n", "enc(train_dataset[0][1])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## آموزش یک RNN مولد\n", "\n", "روش آموزش RNN برای تولید متن به این صورت است. در هر مرحله، یک دنباله از کاراکترها به طول `nchars` را می‌گیریم و از شبکه می‌خواهیم که برای هر کاراکتر ورودی، کاراکتر خروجی بعدی را تولید کند:\n", "\n", "![تصویری که نمونه‌ای از تولید کلمه 'HELLO' توسط RNN را نشان می‌دهد.](../../../../../lessons/5-NLP/17-GenerativeNetworks/images/rnn-generate.png)\n", "\n", "بسته به سناریوی واقعی، ممکن است بخواهیم برخی کاراکترهای خاص مانند *پایان دنباله* `` را نیز شامل کنیم. در مورد ما، هدف فقط آموزش شبکه برای تولید متن بی‌پایان است، بنابراین اندازه هر دنباله را برابر با `nchars` تنظیم می‌کنیم. در نتیجه، هر مثال آموزشی شامل `nchars` ورودی و `nchars` خروجی خواهد بود (که دنباله ورودی یک نماد به سمت چپ جابه‌جا شده است). یک مینی‌بچ شامل چندین دنباله از این نوع خواهد بود.\n", "\n", "روش تولید مینی‌بچ‌ها این است که هر متن خبری به طول `l` را بگیریم و تمام ترکیب‌های ممکن ورودی-خروجی را از آن تولید کنیم (تعداد این ترکیب‌ها برابر با `l-nchars` خواهد بود). این ترکیب‌ها یک مینی‌بچ را تشکیل می‌دهند و اندازه مینی‌بچ‌ها در هر مرحله آموزشی متفاوت خواهد بود.\n" ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "(tensor([[ 0, 1, 2, ..., 28, 29, 30],\n", " [ 1, 2, 2, ..., 29, 30, 1],\n", " [ 2, 2, 3, ..., 30, 1, 16],\n", " ...,\n", " [20, 8, 21, ..., 1, 28, 1],\n", " [ 8, 21, 5, ..., 28, 1, 27],\n", " [21, 5, 8, ..., 1, 27, 16]]),\n", " tensor([[ 1, 2, 2, ..., 29, 30, 1],\n", " [ 2, 2, 3, ..., 30, 1, 16],\n", " [ 2, 3, 4, ..., 1, 16, 26],\n", " ...,\n", " [ 8, 21, 5, ..., 28, 1, 27],\n", " [21, 5, 8, ..., 1, 27, 16],\n", " [ 5, 8, 9, ..., 27, 16, 6]]))" ] }, "execution_count": 4, "metadata": {}, "output_type": "execute_result" } ], "source": [ "nchars = 100\n", "\n", "def get_batch(s,nchars=nchars):\n", " ins = torch.zeros(len(s)-nchars,nchars,dtype=torch.long,device=device)\n", " outs = torch.zeros(len(s)-nchars,nchars,dtype=torch.long,device=device)\n", " for i in range(len(s)-nchars):\n", " ins[i] = enc(s[i:i+nchars])\n", " outs[i] = enc(s[i+1:i+nchars+1])\n", " return ins,outs\n", "\n", "get_batch(train_dataset[0][1])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "حالا بیایید شبکه تولیدکننده را تعریف کنیم. این شبکه می‌تواند بر اساس هر سلول بازگشتی که در واحد قبلی بررسی کردیم (ساده، LSTM یا GRU) باشد. در مثال ما از LSTM استفاده خواهیم کرد.\n", "\n", "از آنجا که شبکه کاراکترها را به عنوان ورودی دریافت می‌کند و اندازه واژگان نسبتاً کوچک است، نیازی به لایه تعبیه نیست؛ ورودی کدگذاری‌شده به صورت یک‌داغ می‌تواند مستقیماً به سلول LSTM ارسال شود. با این حال، چون ما شماره‌های کاراکترها را به عنوان ورودی ارسال می‌کنیم، باید قبل از ارسال به LSTM آنها را به صورت یک‌داغ کدگذاری کنیم. این کار با فراخوانی تابع `one_hot` در طول عبور `forward` انجام می‌شود. رمزگذار خروجی یک لایه خطی خواهد بود که حالت مخفی را به خروجی کدگذاری‌شده به صورت یک‌داغ تبدیل می‌کند.\n" ] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [], "source": [ "class LSTMGenerator(torch.nn.Module):\n", " def __init__(self, vocab_size, hidden_dim):\n", " super().__init__()\n", " self.rnn = torch.nn.LSTM(vocab_size,hidden_dim,batch_first=True)\n", " self.fc = torch.nn.Linear(hidden_dim, vocab_size)\n", "\n", " def forward(self, x, s=None):\n", " x = torch.nn.functional.one_hot(x,vocab_size).to(torch.float32)\n", " x,s = self.rnn(x,s)\n", " return self.fc(x),s" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "در طول آموزش، ما می‌خواهیم بتوانیم متن تولید شده را نمونه‌برداری کنیم. برای این کار، تابعی به نام `generate` تعریف می‌کنیم که رشته‌ای به طول `size` تولید می‌کند و از رشته اولیه `start` شروع می‌شود.\n", "\n", "روش کار به این صورت است: ابتدا، کل رشته `start` را از طریق شبکه عبور می‌دهیم و حالت خروجی `s` و کاراکتر پیش‌بینی‌شده بعدی `out` را دریافت می‌کنیم. از آنجا که `out` به صورت یک‌کدگذاری یک‌داغ است، از `argmax` استفاده می‌کنیم تا شاخص کاراکتر `nc` را در واژه‌نامه پیدا کنیم و با استفاده از `itos` کاراکتر واقعی را مشخص کرده و به لیست کاراکترهای نتیجه `chars` اضافه کنیم. این فرآیند تولید یک کاراکتر، به تعداد `size` بار تکرار می‌شود تا تعداد کاراکترهای مورد نیاز تولید شود.\n" ] }, { "cell_type": "code", "execution_count": 8, "metadata": {}, "outputs": [], "source": [ "def generate(net,size=100,start='today '):\n", " chars = list(start)\n", " out, s = net(enc(chars).view(1,-1).to(device))\n", " for i in range(size):\n", " nc = torch.argmax(out[0][-1])\n", " chars.append(vocab.get_itos()[nc])\n", " out, s = net(nc.view(1,-1),s)\n", " return ''.join(chars)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "حالا بیایید آموزش را شروع کنیم! حلقه آموزش تقریباً مشابه تمام مثال‌های قبلی ما است، اما به جای دقت، متن تولید شده نمونه‌ای را هر ۱۰۰۰ دوره چاپ می‌کنیم.\n", "\n", "توجه ویژه‌ای باید به نحوه محاسبه خطا شود. ما باید خطا را با توجه به خروجی یک‌داغ‌شده `out` و متن مورد انتظار `text_out` که لیستی از شاخص‌های کاراکتر است، محاسبه کنیم. خوشبختانه، تابع `cross_entropy` خروجی شبکه غیرنرمال‌شده را به عنوان آرگومان اول و شماره کلاس را به عنوان آرگومان دوم انتظار دارد، که دقیقاً همان چیزی است که داریم. این تابع همچنین میانگین‌گیری خودکار بر اساس اندازه مینی‌بچ را انجام می‌دهد.\n", "\n", "ما همچنین آموزش را با محدود کردن به تعداد نمونه‌های `samples_to_train` محدود می‌کنیم تا زمان زیادی منتظر نمانیم. شما را تشویق می‌کنیم که آزمایش کنید و آموزش طولانی‌تر را امتحان کنید، شاید برای چندین دوره (که در این صورت نیاز دارید یک حلقه دیگر در اطراف این کد ایجاد کنید).\n" ] }, { "cell_type": "code", "execution_count": 9, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Current loss = 4.398899078369141\n", "today sr sr sr sr sr sr sr sr sr sr sr sr sr sr sr sr sr sr sr sr sr sr sr sr sr sr sr sr sr sr sr sr sr s\n", "Current loss = 2.161320447921753\n", "today and to the tor to to the tor to to the tor to to the tor to to the tor to to the tor to to the tor t\n", "Current loss = 1.6722588539123535\n", "today and the court to the could to the could to the could to the could to the could to the could to the c\n", "Current loss = 2.423795223236084\n", "today and a second to the conternation of the conternation of the conternation of the conternation of the \n", "Current loss = 1.702607274055481\n", "today and the company to the company to the company to the company to the company to the company to the co\n", "Current loss = 1.692358136177063\n", "today and the company to the company to the company to the company to the company to the company to the co\n", "Current loss = 1.9722288846969604\n", "today and the control the control the control the control the control the control the control the control \n", "Current loss = 1.8705692291259766\n", "today and the second to the second to the second to the second to the second to the second to the second t\n", "Current loss = 1.7626899480819702\n", "today and a security and a security and a security and a security and a security and a security and a secu\n", "Current loss = 1.5574463605880737\n", "today and the company and the company and the company and the company and the company and the company and \n", "Current loss = 1.5620026588439941\n", "today and the be that the be the be that the be the be that the be the be that the be the be that the be t\n" ] } ], "source": [ "net = LSTMGenerator(vocab_size,64).to(device)\n", "\n", "samples_to_train = 10000\n", "optimizer = torch.optim.Adam(net.parameters(),0.01)\n", "loss_fn = torch.nn.CrossEntropyLoss()\n", "net.train()\n", "for i,x in enumerate(train_dataset):\n", " # x[0] is class label, x[1] is text\n", " if len(x[1])-nchars<10:\n", " continue\n", " samples_to_train-=1\n", " if not samples_to_train: break\n", " text_in, text_out = get_batch(x[1])\n", " optimizer.zero_grad()\n", " out,s = net(text_in)\n", " loss = torch.nn.functional.cross_entropy(out.view(-1,vocab_size),text_out.flatten()) #cross_entropy(out,labels)\n", " loss.backward()\n", " optimizer.step()\n", " if i%1000==0:\n", " print(f\"Current loss = {loss.item()}\")\n", " print(generate(net))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "این مثال از قبل متن نسبتاً خوبی تولید می‌کند، اما می‌توان آن را به چندین روش بهبود داد:\n", "\n", "* **بهبود تولید مینی‌بچ‌ها**. روشی که برای آماده‌سازی داده‌ها برای آموزش استفاده کردیم، تولید یک مینی‌بچ از یک نمونه بود. این روش ایده‌آل نیست، زیرا اندازه مینی‌بچ‌ها متفاوت است و برخی از آن‌ها حتی قابل تولید نیستند، چون متن کوچک‌تر از `nchars` است. همچنین، مینی‌بچ‌های کوچک به اندازه کافی از GPU استفاده نمی‌کنند. بهتر است یک بخش بزرگ از متن را از تمام نمونه‌ها بگیریم، سپس تمام جفت‌های ورودی-خروجی را تولید کنیم، آن‌ها را به هم بزنیم و مینی‌بچ‌هایی با اندازه برابر تولید کنیم.\n", "\n", "* **LSTM چندلایه**. منطقی است که 2 یا 3 لایه از سلول‌های LSTM را امتحان کنیم. همان‌طور که در واحد قبلی اشاره کردیم، هر لایه از LSTM الگوهای خاصی را از متن استخراج می‌کند، و در مورد تولیدکننده سطح کاراکتر می‌توان انتظار داشت که لایه‌های پایین‌تر LSTM مسئول استخراج هجاها باشند و لایه‌های بالاتر - کلمات و ترکیب‌های کلمات. این کار به سادگی با ارسال پارامتر تعداد لایه‌ها به سازنده LSTM قابل اجرا است.\n", "\n", "* همچنین ممکن است بخواهید با **واحدهای GRU** آزمایش کنید و ببینید کدام یک عملکرد بهتری دارند، و با **اندازه‌های مختلف لایه‌های مخفی** نیز آزمایش کنید. لایه مخفی بیش از حد بزرگ ممکن است منجر به بیش‌برازش شود (مثلاً شبکه متن دقیق را یاد می‌گیرد)، و اندازه کوچک‌تر ممکن است نتیجه خوبی تولید نکند.\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## تولید متن نرم و دما\n", "\n", "در تعریف قبلی `generate`، ما همیشه کاراکتری را که بالاترین احتمال را داشت به عنوان کاراکتر بعدی در متن تولید شده انتخاب می‌کردیم. این باعث می‌شد که متن اغلب بین توالی‌های کاراکتری مشابه بارها و بارها \"چرخه\" بزند، مانند این مثال:\n", "```\n", "today of the second the company and a second the company ...\n", "```\n", "\n", "با این حال، اگر به توزیع احتمالات برای کاراکتر بعدی نگاه کنیم، ممکن است تفاوت بین چند احتمال بالاتر خیلی زیاد نباشد، به عنوان مثال، یک کاراکتر می‌تواند احتمال ۰.۲ داشته باشد و دیگری ۰.۱۹ و غیره. برای مثال، وقتی به دنبال کاراکتر بعدی در توالی '*play*' هستیم، کاراکتر بعدی می‌تواند به همان اندازه احتمالاً یک فاصله باشد یا **e** (مانند کلمه *player*).\n", "\n", "این ما را به این نتیجه می‌رساند که همیشه \"منصفانه\" نیست که کاراکتری با احتمال بالاتر را انتخاب کنیم، زیرا انتخاب کاراکتر دوم با احتمال بالاتر نیز ممکن است به متنی معنادار منجر شود. عاقلانه‌تر این است که **نمونه‌گیری** از کاراکترها را بر اساس توزیع احتمالاتی که توسط خروجی شبکه داده شده انجام دهیم.\n", "\n", "این نمونه‌گیری می‌تواند با استفاده از تابع `multinomial` که توزیع موسوم به **توزیع چندجمله‌ای** را پیاده‌سازی می‌کند، انجام شود. تابعی که این تولید متن **نرم** را پیاده‌سازی می‌کند در زیر تعریف شده است:\n" ] }, { "cell_type": "code", "execution_count": 10, "metadata": { "scrolled": true }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "--- Temperature = 0.3\n", "Today and a company and complete an all the land the restrational the as a security and has provers the pay to and a report and the computer in the stand has filities and working the law the stations for a company and with the company and the final the first company and refight of the state and and workin\n", "\n", "--- Temperature = 0.8\n", "Today he oniis its first to Aus bomblaties the marmation a to manan boogot that pirate assaid a relaid their that goverfin the the Cappets Ecrotional Assonia Cition targets it annight the w scyments Blamity #39;s TVeer Diercheg Reserals fran envyuil that of ster said access what succers of Dour-provelith\n", "\n", "--- Temperature = 1.0\n", "Today holy they a 11 will meda a toket subsuaties, engins for Chanos, they's has stainger past to opening orital his thempting new Nattona was al innerforder advan-than #36;s night year his religuled talitatian what the but with Wednesday to Justment will wemen of Mark CCC Camp as Timed Nae wome a leaders\n", "\n", "--- Temperature = 1.3\n", "Today gpone 2.5 fech atcusion poor cocles toparsdorM.cht Line Pamage put 43 his calt lowed to the book, that has authh-the silia rruch ailing to'ory andhes beutirsimi- Aefffive heading offil an auf eacklets is charged evis, Gunymy oy) Mony has it after-sloythyor loveId out filme, the Natabl -Najuntaxiggs \n", "\n", "--- Temperature = 1.8\n", "Today plary, P.slan chly\\401 mardregationly #39;t 8.1Mide) closes ,filtcon alfly playin roven!\\grea.-QFBEP: Iss onfarchQ/itilia CCf Zivesigntwasta orce.-Peul-aw.uicrin of fuglinfsut aftaningwo, MIEX awayew Aice Woiduar Corvagiugge oppo esig ThusBratourid canthly-RyI.co lagitems\\eexciaishes.conBabntusmor I\n", "\n" ] } ], "source": [ "def generate_soft(net,size=100,start='today ',temperature=1.0):\n", " chars = list(start)\n", " out, s = net(enc(chars).view(1,-1).to(device))\n", " for i in range(size):\n", " #nc = torch.argmax(out[0][-1])\n", " out_dist = out[0][-1].div(temperature).exp()\n", " nc = torch.multinomial(out_dist,1)[0]\n", " chars.append(vocab.get_itos()[nc])\n", " out, s = net(nc.view(1,-1),s)\n", " return ''.join(chars)\n", " \n", "for i in [0.3,0.8,1.0,1.3,1.8]:\n", " print(f\"--- Temperature = {i}\\n{generate_soft(net,size=300,start='Today ',temperature=i)}\\n\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "ما یک پارامتر دیگر به نام **دما** معرفی کرده‌ایم که برای نشان دادن میزان پایبندی به بالاترین احتمال استفاده می‌شود. اگر دما ۱.۰ باشد، نمونه‌گیری چندجمله‌ای منصفانه انجام می‌دهیم، و زمانی که دما به بی‌نهایت می‌رسد - همه احتمالات برابر می‌شوند و ما به صورت تصادفی کاراکتر بعدی را انتخاب می‌کنیم. در مثال زیر می‌توان مشاهده کرد که متن زمانی که دما بیش از حد افزایش می‌یابد بی‌معنی می‌شود، و زمانی که به ۰ نزدیک‌تر می‌شود به متن \"چرخه‌ای\" سخت‌تولید شده شباهت پیدا می‌کند.\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "\n---\n\n**سلب مسئولیت**: \nاین سند با استفاده از سرویس ترجمه هوش مصنوعی [Co-op Translator](https://github.com/Azure/co-op-translator) ترجمه شده است. در حالی که ما تلاش می‌کنیم دقت را حفظ کنیم، لطفاً توجه داشته باشید که ترجمه‌های خودکار ممکن است شامل خطاها یا نادرستی‌ها باشند. سند اصلی به زبان اصلی آن باید به عنوان منبع معتبر در نظر گرفته شود. برای اطلاعات حساس، توصیه می‌شود از ترجمه حرفه‌ای انسانی استفاده کنید. ما مسئولیتی در قبال سوء تفاهم‌ها یا تفسیرهای نادرست ناشی از استفاده از این ترجمه نداریم.\n" ] } ], "metadata": { "interpreter": { "hash": "16af2a8bbb083ea23e5e41c7f5787656b2ce26968575d8763f2c4b17f9cd711f" }, "kernelspec": { "display_name": "Python 3.8.12 ('py38')", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.8.12" }, "coopTranslator": { "original_hash": "7673cd150d96c74c6d6011460094efb4", "translation_date": "2025-08-31T16:54:25+00:00", "source_file": "lessons/5-NLP/17-GenerativeNetworks/GenerativePyTorch.ipynb", "language_code": "fa" } }, "nbformat": 4, "nbformat_minor": 4 }