[{"data":1,"prerenderedAt":795},["ShallowReactive",2],{"content-\u002Fcontents\u002Fpreprocessing":3,"surroundPost-\u002Fcontents\u002Fpreprocessing":786},{"id":4,"title":5,"body":6,"createdAt":773,"description":774,"draft":775,"extension":776,"meta":777,"navigation":44,"path":778,"seo":779,"stem":780,"tags":781,"thumbnail":784,"updatedAt":773,"__hash__":785},"contents\u002Fcontents\u002Fpreprocessing.md","前処理を行いツイートをWordCloudで可視化する方法",{"type":7,"value":8,"toc":765},"minimark",[9,13,16,41,47,50,72,76,79,87,90,110,113,116,122,125,128,131,137,140,258,261,310,313,316,551,561,564,569,572,576,579,655,662,665,669,672,675,678,681,684,695,698,701,704,707,713,716,719,722,725,728,737,740,761],[10,11,12],"p",{},"本記事では、Twitter API で集めたツイートに前処理を行い、WordCloud で可視化する方法を紹介します。",[10,14,15],{},"最終的にこんな感じになります。",[17,18,21,22,36,37],"blockquote",{"className":19},[20],"twitter-tweet","\n  ",[10,23,26,27,30,31,21],{"lang":24,"dir":25},"ja","ltr","\n    twitter検索で「ブログ 初心者」で検索したツイートをwordcloudにしてみた\n    ",[28,29],"br",{},"\n    検索キーワードを除いて、各単語は見出し語にするとこんな感じになった\n    ",[32,33,35],"a",{"href":34},"https:\u002F\u002Ft.co\u002FOu7OrDplRu","pic.twitter.com\u002FOu7OrDplRu","\n  — おいまる@ブログエンジョイ勢 (@oio_blog)\n  ",[32,38,40],{"href":39},"https:\u002F\u002Ftwitter.com\u002Foio_blog\u002Fstatus\u002F1242950959237488640?ref_src=twsrc%5Etfw","\n    March 25, 2020\n  ",[42,43],"script",{"async":44,"src":45,"charSet":46},true,"https:\u002F\u002Fplatform.twitter.com\u002Fwidgets.js","utf-8",[10,48,49],{},"Twitter API やツイート収集システムについては以下の記事で紹介しました。",[51,52,53,60,66],"ul",{},[54,55,56],"li",{},[32,57,59],{"href":58},"about-twitter-api","Python と Twitter API を利用したツイート収集方法",[54,61,62],{},[32,63,65],{"href":64},"aws-lambda-upload-docker","AWS Lambda を使ったツイート収集システム",[54,67,68],{},[32,69,71],{"href":70},"ginza-docker","Docker で GiNZA の環境構築をしてみた|SudachPy のユーザー辞書登録方法も紹介",[73,74,75],"h2",{"id":75},"前処理概要",[10,77,78],{},"日常用いられる文章は実は様々な形をしていて、そのままではプログラムに読み込ませて解析をするのは難しいです。",[10,80,81,82,86],{},"そこで、自然言語処理では解析に入る前にテキストを解析に適した状態にする",[83,84,85],"strong",{},"前処理","が必要になります。",[10,88,89],{},"今回行う具体的な前処理の中身は以下の通りです。",[91,92,21,95],"div",{"className":93},[94],"point-box",[51,96,97,98,97,101,97,104,97,107,21],{},"\n    ",[54,99,100],{},"不要情報の除去（記号・URLなど）",[54,102,103],{},"正規化（大文字小文字変換、半角全角統一、数字を０になど）",[54,105,106],{},"形態素解析（単語分割、品詞指定）",[54,108,109],{},"ストップワードの除去",[10,111,112],{},"おそらくもっと正確にやるなら同義語辞書による変換も必要になるでしょう。",[10,114,115],{},"ユーザー辞書登録については以前紹介した記事で行っているので、形態素解析で反映されてます。",[51,117,118],{},[54,119,120],{},[32,121,71],{"href":70},[73,123,124],{"id":124},"ツイートデータを前処理",[10,126,127],{},"Twitter API で収集したツイートに前処理を行います。",[10,129,130],{},"実行環境は以下の記事にあるように Docker で行い、コードは Jupyter NoteBook に書いていきます。",[51,132,133],{},[54,134,135],{},[32,136,71],{"href":70},[10,138,139],{},"まずは pandas でツイートを保存した csv を読み込みます。",[141,142,147],"pre",{"className":143,"code":144,"language":145,"meta":146,"style":146},"language-py shiki shiki-themes github-dark","import os\nimport pandas as pd\nimport numpy as np\nimport emoji\nimport spacy\nimport re\nimport neologdn\nimport json\nfrom wordcloud import WordCloud\nfrom collections import Counter\n\npath = \"\u003Cfile path>\"\nfile_name = \"\u003Cfile name>.csv\"\n\ndf = pd.read_csv(os.path.join(path,file_name))\n\n# 必要な列を定義。textがツイート、user_mentionsはツイートに含まれるメンション\ncolumn = [\"text\", \"user_mentions\"]\n","py","",[148,149,150,158,164,170,176,182,188,194,200,206,212,218,224,230,235,241,246,252],"code",{"__ignoreMap":146},[151,152,155],"span",{"class":153,"line":154},"line",1,[151,156,157],{},"import os\n",[151,159,161],{"class":153,"line":160},2,[151,162,163],{},"import pandas as pd\n",[151,165,167],{"class":153,"line":166},3,[151,168,169],{},"import numpy as np\n",[151,171,173],{"class":153,"line":172},4,[151,174,175],{},"import emoji\n",[151,177,179],{"class":153,"line":178},5,[151,180,181],{},"import spacy\n",[151,183,185],{"class":153,"line":184},6,[151,186,187],{},"import re\n",[151,189,191],{"class":153,"line":190},7,[151,192,193],{},"import neologdn\n",[151,195,197],{"class":153,"line":196},8,[151,198,199],{},"import json\n",[151,201,203],{"class":153,"line":202},9,[151,204,205],{},"from wordcloud import WordCloud\n",[151,207,209],{"class":153,"line":208},10,[151,210,211],{},"from collections import Counter\n",[151,213,215],{"class":153,"line":214},11,[151,216,217],{"emptyLinePlaceholder":44},"\n",[151,219,221],{"class":153,"line":220},12,[151,222,223],{},"path = \"\u003Cfile path>\"\n",[151,225,227],{"class":153,"line":226},13,[151,228,229],{},"file_name = \"\u003Cfile name>.csv\"\n",[151,231,233],{"class":153,"line":232},14,[151,234,217],{"emptyLinePlaceholder":44},[151,236,238],{"class":153,"line":237},15,[151,239,240],{},"df = pd.read_csv(os.path.join(path,file_name))\n",[151,242,244],{"class":153,"line":243},16,[151,245,217],{"emptyLinePlaceholder":44},[151,247,249],{"class":153,"line":248},17,[151,250,251],{},"# 必要な列を定義。textがツイート、user_mentionsはツイートに含まれるメンション\n",[151,253,255],{"class":153,"line":254},18,[151,256,257],{},"column = [\"text\", \"user_mentions\"]\n",[10,259,260],{},"次にツイートに含まれるメンションを除きます。メンションは API の結果にツイート本文とは別に抽出できるようになっているので、csv 作成時にあらかじめ列にいれておきました。",[141,262,264],{"className":143,"code":263,"language":145,"meta":146,"style":146},"bool_list = df[\"user_mentions\"].isna()\n\nfor idx, bol in enumerate(bool_list):\n    if bol:\n        continue\n    else:\n        mention_list = df[\"user_mentions\"].copy().loc[idx].split(\",\")\n        for mention in mention_list:\n            df[\"text\"].loc[idx] = df[\"text\"].copy().loc[idx].replace(\"@\"+mention, '')\n",[148,265,266,271,275,280,285,290,295,300,305],{"__ignoreMap":146},[151,267,268],{"class":153,"line":154},[151,269,270],{},"bool_list = df[\"user_mentions\"].isna()\n",[151,272,273],{"class":153,"line":160},[151,274,217],{"emptyLinePlaceholder":44},[151,276,277],{"class":153,"line":166},[151,278,279],{},"for idx, bol in enumerate(bool_list):\n",[151,281,282],{"class":153,"line":172},[151,283,284],{},"    if bol:\n",[151,286,287],{"class":153,"line":178},[151,288,289],{},"        continue\n",[151,291,292],{"class":153,"line":184},[151,293,294],{},"    else:\n",[151,296,297],{"class":153,"line":190},[151,298,299],{},"        mention_list = df[\"user_mentions\"].copy().loc[idx].split(\",\")\n",[151,301,302],{"class":153,"line":196},[151,303,304],{},"        for mention in mention_list:\n",[151,306,307],{"class":153,"line":202},[151,308,309],{},"            df[\"text\"].loc[idx] = df[\"text\"].copy().loc[idx].replace(\"@\"+mention, '')\n",[10,311,312],{},"python 的にあまりよくない書き方かもしれないですが、とりあえずこれでメンション部分をツイートから除外できます。",[10,314,315],{},"さて、いよいよ前処理部分です。（メンション除外も前処理っぽいですが、、）",[141,317,319],{"className":143,"code":318,"language":145,"meta":146,"style":146},"def preprocess_wordcloud(doc):\n\n    # 絵文字削除\n    doc = ''.join(['' if c in emoji.UNICODE_EMOJI else c for c in doc])\n    print(\"絵文字削除\")\n\n    # 不要記号削除\n    pattern = '[!\"#$%&\\'\\\\\\\\()*+,-.\u002F:;\u003C=>?@[\\\\]^_`{|}~「」〔〕“”◇ᴗ●↓→♪★⊂⊃※△□◎〈〉『』【】＆＊・（）＄＃＠。、？！｀＋￥％]'\n    doc =  re.sub(pattern, ' ', doc)\n    print(\"不要記号削除\")\n\n    doc = neologdn.normalize(doc)\n    print(\"正規化\")\n\n    # 大文字・小文字変換\n    doc = doc.lower()\n    print(\"大文字小文字変換\")\n\n    # 数字を０に\n    doc = re.sub(r'\\d+', '0', doc)\n    print(\"数字をゼロに\")\n\n    # 形態素解析\n    print(\"形態素解析開始\")\n    nlp = spacy.load('ja_ginza')\n    doc = nlp(doc)\n    x = []\n    for sent in doc.sents:\n        for token in sent:\n            if token.pos_ in [\"PROPN\",\"NOUN\"]:\n                x.append(token.lemma_)\n\n    print(\"形態素解析完了\")\n\n    with open(\"\u003Cstopword file path>.txt\", \"r\", encoding=\"utf-8\") as f:\n        stopwords = [word.replace('\\n', '') for word in f.readlines()]\n\n    x = [c for c in x if c not in stopwords]\n    print(\"ストップワード除去\")\n    print(len(x))\n    return x\n\ntext = preprocess_wordcloud(doc)\n",[148,320,321,326,330,335,340,345,349,354,359,364,369,373,378,383,387,392,397,402,406,412,418,424,429,435,441,447,453,459,465,471,477,483,488,494,499,505,511,516,522,528,534,540,545],{"__ignoreMap":146},[151,322,323],{"class":153,"line":154},[151,324,325],{},"def preprocess_wordcloud(doc):\n",[151,327,328],{"class":153,"line":160},[151,329,217],{"emptyLinePlaceholder":44},[151,331,332],{"class":153,"line":166},[151,333,334],{},"    # 絵文字削除\n",[151,336,337],{"class":153,"line":172},[151,338,339],{},"    doc = ''.join(['' if c in emoji.UNICODE_EMOJI else c for c in doc])\n",[151,341,342],{"class":153,"line":178},[151,343,344],{},"    print(\"絵文字削除\")\n",[151,346,347],{"class":153,"line":184},[151,348,217],{"emptyLinePlaceholder":44},[151,350,351],{"class":153,"line":190},[151,352,353],{},"    # 不要記号削除\n",[151,355,356],{"class":153,"line":196},[151,357,358],{},"    pattern = '[!\"#$%&\\'\\\\\\\\()*+,-.\u002F:;\u003C=>?@[\\\\]^_`{|}~「」〔〕“”◇ᴗ●↓→♪★⊂⊃※△□◎〈〉『』【】＆＊・（）＄＃＠。、？！｀＋￥％]'\n",[151,360,361],{"class":153,"line":202},[151,362,363],{},"    doc =  re.sub(pattern, ' ', doc)\n",[151,365,366],{"class":153,"line":208},[151,367,368],{},"    print(\"不要記号削除\")\n",[151,370,371],{"class":153,"line":214},[151,372,217],{"emptyLinePlaceholder":44},[151,374,375],{"class":153,"line":220},[151,376,377],{},"    doc = neologdn.normalize(doc)\n",[151,379,380],{"class":153,"line":226},[151,381,382],{},"    print(\"正規化\")\n",[151,384,385],{"class":153,"line":232},[151,386,217],{"emptyLinePlaceholder":44},[151,388,389],{"class":153,"line":237},[151,390,391],{},"    # 大文字・小文字変換\n",[151,393,394],{"class":153,"line":243},[151,395,396],{},"    doc = doc.lower()\n",[151,398,399],{"class":153,"line":248},[151,400,401],{},"    print(\"大文字小文字変換\")\n",[151,403,404],{"class":153,"line":254},[151,405,217],{"emptyLinePlaceholder":44},[151,407,409],{"class":153,"line":408},19,[151,410,411],{},"    # 数字を０に\n",[151,413,415],{"class":153,"line":414},20,[151,416,417],{},"    doc = re.sub(r'\\d+', '0', doc)\n",[151,419,421],{"class":153,"line":420},21,[151,422,423],{},"    print(\"数字をゼロに\")\n",[151,425,427],{"class":153,"line":426},22,[151,428,217],{"emptyLinePlaceholder":44},[151,430,432],{"class":153,"line":431},23,[151,433,434],{},"    # 形態素解析\n",[151,436,438],{"class":153,"line":437},24,[151,439,440],{},"    print(\"形態素解析開始\")\n",[151,442,444],{"class":153,"line":443},25,[151,445,446],{},"    nlp = spacy.load('ja_ginza')\n",[151,448,450],{"class":153,"line":449},26,[151,451,452],{},"    doc = nlp(doc)\n",[151,454,456],{"class":153,"line":455},27,[151,457,458],{},"    x = []\n",[151,460,462],{"class":153,"line":461},28,[151,463,464],{},"    for sent in doc.sents:\n",[151,466,468],{"class":153,"line":467},29,[151,469,470],{},"        for token in sent:\n",[151,472,474],{"class":153,"line":473},30,[151,475,476],{},"            if token.pos_ in [\"PROPN\",\"NOUN\"]:\n",[151,478,480],{"class":153,"line":479},31,[151,481,482],{},"                x.append(token.lemma_)\n",[151,484,486],{"class":153,"line":485},32,[151,487,217],{"emptyLinePlaceholder":44},[151,489,491],{"class":153,"line":490},33,[151,492,493],{},"    print(\"形態素解析完了\")\n",[151,495,497],{"class":153,"line":496},34,[151,498,217],{"emptyLinePlaceholder":44},[151,500,502],{"class":153,"line":501},35,[151,503,504],{},"    with open(\"\u003Cstopword file path>.txt\", \"r\", encoding=\"utf-8\") as f:\n",[151,506,508],{"class":153,"line":507},36,[151,509,510],{},"        stopwords = [word.replace('\\n', '') for word in f.readlines()]\n",[151,512,514],{"class":153,"line":513},37,[151,515,217],{"emptyLinePlaceholder":44},[151,517,519],{"class":153,"line":518},38,[151,520,521],{},"    x = [c for c in x if c not in stopwords]\n",[151,523,525],{"class":153,"line":524},39,[151,526,527],{},"    print(\"ストップワード除去\")\n",[151,529,531],{"class":153,"line":530},40,[151,532,533],{},"    print(len(x))\n",[151,535,537],{"class":153,"line":536},41,[151,538,539],{},"    return x\n",[151,541,543],{"class":153,"line":542},42,[151,544,217],{"emptyLinePlaceholder":44},[151,546,548],{"class":153,"line":547},43,[151,549,550],{},"text = preprocess_wordcloud(doc)\n",[10,552,553,554,560],{},"形態素解析の結果のうち、固有名詞・名詞だけを抽出するようにしてます。指定する POS タグについては",[32,555,559],{"href":556,"rel":557},"https:\u002F\u002Fqiita.com\u002Fkei_0324\u002Fitems\u002F400f639b2f185b39a0cf",[558],"nofollow","こちらの記事","が見やすいです。",[10,562,563],{},"実行結果は以下のようになります。",[565,566],"img",{"alt":567,"img-src":568},"前処理結果","\u002Fimg\u002Fpreprocessing\u002Fpart1.jpg",[10,570,571],{},"自分のコードの書き方だと形態素解析が思ったより時間がかかります。といっても数分程度です。ただメモリ 8GB の古い PC だと処理が終わらなかったです。",[73,573,575],{"id":574},"wordcloud-の作成","WordCloud の作成",[10,577,578],{},"続いては前処理済みのデータで WordCloud を作成します。",[141,580,582],{"className":143,"code":581,"language":145,"meta":146,"style":146},"# リストをスペース区切りの分かち書きに\ntext_list = \" \".join(text)\n\n# 検索ワードは多すぎるので除外\ntext_list = text_list.replace(\"ブログ\",\"\")\ntext_list = text_list.replace(\"初心者\",\"\")\n\n# WordCloud定義\nwordcloud = WordCloud(width=600, height=400,font_path=\"\u002Froot\u002F.fonts\u002FNotoSansCJKjp-Regular.otf\",background_color=\"black\",collocations = False)\n\n# テキストからワードクラウドを生成する。\nwc = wordcloud.generate(text_list)\n\n#画像ファイルとして保存\nwc.to_file('wordcloud-ブログ-初心者.png')\n",[148,583,584,589,594,598,603,608,613,617,622,627,631,636,641,645,650],{"__ignoreMap":146},[151,585,586],{"class":153,"line":154},[151,587,588],{},"# リストをスペース区切りの分かち書きに\n",[151,590,591],{"class":153,"line":160},[151,592,593],{},"text_list = \" \".join(text)\n",[151,595,596],{"class":153,"line":166},[151,597,217],{"emptyLinePlaceholder":44},[151,599,600],{"class":153,"line":172},[151,601,602],{},"# 検索ワードは多すぎるので除外\n",[151,604,605],{"class":153,"line":178},[151,606,607],{},"text_list = text_list.replace(\"ブログ\",\"\")\n",[151,609,610],{"class":153,"line":184},[151,611,612],{},"text_list = text_list.replace(\"初心者\",\"\")\n",[151,614,615],{"class":153,"line":190},[151,616,217],{"emptyLinePlaceholder":44},[151,618,619],{"class":153,"line":196},[151,620,621],{},"# WordCloud定義\n",[151,623,624],{"class":153,"line":202},[151,625,626],{},"wordcloud = WordCloud(width=600, height=400,font_path=\"\u002Froot\u002F.fonts\u002FNotoSansCJKjp-Regular.otf\",background_color=\"black\",collocations = False)\n",[151,628,629],{"class":153,"line":208},[151,630,217],{"emptyLinePlaceholder":44},[151,632,633],{"class":153,"line":214},[151,634,635],{},"# テキストからワードクラウドを生成する。\n",[151,637,638],{"class":153,"line":220},[151,639,640],{},"wc = wordcloud.generate(text_list)\n",[151,642,643],{"class":153,"line":226},[151,644,217],{"emptyLinePlaceholder":44},[151,646,647],{"class":153,"line":232},[151,648,649],{},"#画像ファイルとして保存\n",[151,651,652],{"class":153,"line":237},[151,653,654],{},"wc.to_file('wordcloud-ブログ-初心者.png')\n",[10,656,657,658,661],{},"これで WordCloud が生成できます。",[148,659,660],{},"collocations=False","とすることで複合語を表示しないようにできます。",[10,663,664],{},"結果は以下です。ちなみにデータは Twitter API で「ブログ 初心者」と検索して得られた結果です。",[565,666],{"alt":667,"img-src":668},"wordcloud ブログ初心者","\u002Fimg\u002Fpreprocessing\u002Fwordcloud.jpg",[10,670,671],{},"本記事の最初でツイートにのせたものはツイートの動詞や形容詞なども含まれており、さらに複合語もありにしていたのでやや見づらいです。",[10,673,674],{},"名詞・固有名詞と複合語をなしにするとシンプルで見やすいです。",[10,676,677],{},"WOrdCloud の結果については、「記事」はわかるとしても「収益」がこんなデカさになるのは面白いですね。みんな収益目当てでやっていると推測できます。",[73,679,680],{"id":680},"ハマったところ",[10,682,683],{},"ハマったところは以下です。",[91,685,21,687],{"className":686},[94],[51,688,97,689,97,692,21],{},[54,690,691],{},"GiNZAで使われているSudachiPYのユーザー辞書登録",[54,693,694],{},"WordCloudの文字化け回避",[10,696,697],{},"1 つ目については、Docker で環境を作成したのですが、どこのパスに必要な sudach.json などがあるのか混乱しました。また、GiNZA で使われているものとインストールされた SudachiPy の方にも同じようなファイルがあるので、どちらが正しいのかもわからなかったです。",[10,699,700],{},"結局、公式のドキュメントをしっかり読んだら解決できました。",[10,702,703],{},"2 つめの WordCloud の文字化けは、フォントパスを指定しないと日本語が文字化けします。\nこれは情報がネットにたくさんあったのでそこまではまらなかったです。どちらかというと、日本語フォントをダウンロードして設定する工程を自動化するのに時間がかかりました。",[10,705,706],{},"ここらへんのハマりポイントも前回の記事の環境構築で回避できているはずです。",[51,708,709],{},[54,710,711],{},[32,712,71],{"href":70},[73,714,715],{"id":715},"まとめ",[10,717,718],{},"本記事では GiNZA を使ったツイートの前処理について書きました。",[10,720,721],{},"かなり基本的な前処理しかしていませんが、単語を眺めるには十分でした。",[10,723,724],{},"GiNZA はもっと高度なことが可能で他の機能も使ってさらに有意義な解析も行ってみたいです。",[10,726,727],{},"以下の記事が GiNZA の使い方についてかなり詳しく書いてあります。",[51,729,730],{},[54,731,732],{},[32,733,736],{"href":734,"rel":735},"https:\u002F\u002Fwww.ogis-ri.co.jp\u002Fotc\u002Fhiroba\u002Ftechnical\u002Fsimilar-document-search\u002Fpart4.html",[558],"はじめての自然言語処理",[73,738,739],{"id":739},"参考書籍",[51,741,742,754],{},[54,743,744],{},[32,745,748,749,753],{"href":746,"rel":747},"https:\u002F\u002Famzn.to\u002F3dXa6gu",[558],"機械学習・深層学習による自然言語処理入門 ",[750,751,752],"del",{},"scikit-learn と TensorFlow を使った実践プログラミング"," (Compass Data Science)",[54,755,756],{},[32,757,760],{"href":758,"rel":759},"https:\u002F\u002Famzn.to\u002F3aGOfI5",[558],"機械学習のための特徴量エンジニアリング ―その原理と Python による実践 (オライリー・ジャパン)",[762,763,764],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":146,"searchDepth":160,"depth":160,"links":766},[767,768,769,770,771,772],{"id":75,"depth":160,"text":75},{"id":124,"depth":160,"text":124},{"id":574,"depth":160,"text":575},{"id":680,"depth":160,"text":680},{"id":715,"depth":160,"text":715},{"id":739,"depth":160,"text":739},"2020-03-31","Twitter APIで収集したツイートにPythonで前処理を行い、WordCloudで可視化する方法を紹介します。環境構築はDockerで行い、ツイートの解析にはGiNZAを利用します。自然言語処理の基本的な前処理の書き方について紹介してます、",false,"md",{},"\u002Fcontents\u002Fpreprocessing",{"title":5,"description":774},"contents\u002Fpreprocessing",[782,783],"Python","NLP","\u002Fimg\u002Ftwitter-card.png","gI88B66mu7e-9W8MWqKmy80ZdBs9F08mFIcQ5iflWZg",[787,791],{"title":788,"path":789,"stem":790,"children":-1},"自然言語処理再入門：TfIdfと次元圧縮手法で文章を可視化","\u002Fcontents\u002Fpreprocess","contents\u002Fpreprocess",{"title":792,"path":793,"stem":794,"children":-1},"PRO-LONG: Programmatic Memory Enables Long-Horizon Reasoning","\u002Fcontents\u002Fpro-long-programmatic-memory","contents\u002Fpro-long-programmatic-memory",1784936718212]