大きな配列がメモリを超えたのでCSVファイルにして1行ずつ読み込む(PHP)

前回,検索エンジンに品詞情報を付加する:具体的な検索アルゴリズムの解説(stanza)(PHP)でstanzaを使った具体的な検索方法を考えてみました。

しかしながら,収録するデータが大きくなってきたところでメモリオーバーが発生しました。そこで,データの扱い方を変えることにします。また,検索アルゴリズムにも不具合があったので修正しています。

CSVファイルを用意する

前回まで json ファイルを使っていましたが,ファイル内のデータを部分的に取り出すには csv 形式の方が良いようです。そこで,stanza を使って抽出したデータを csv で保存します。

base_contents.csv

Medical technology has # greatly in recent years.,argued,announced,advanced,attached,亜細亜大

data.csv

Medical,technology,has,argued,greatly,in,recent,years,.
Medical,technology,has,announced,greatly,in,recent,years,.
Medical,technology,has,advanced,greatly,in,recent,years,.
Medical,technology,has,attached,greatly,in,recent,years,.
Medical,technology,have,argue,greatly,in,recent,year,.
Medical,technology,have,announce,greatly,in,recent,year,.
Medical,technology,have,advanced,greatly,in,recent,year,.
Medical,technology,have,attach,greatly,in,recent,year,.
JJ,NOUN,VERB,VBN,RB,IN,JJ,NOUN,.
JJ,NOUN,VERB,VBN,RB,IN,JJ,NOUN,.
JJ,NOUN,VERB,VBN,RB,IN,JJ,NOUN,.
JJ,NOUN,VERB,VBN,RB,IN,JJ,NOUN,.

1つのレコードは上のようになります。

base_contents.csv には問題文,4つの選択肢,大学名が格納されています。

data.csv にはそれぞれの選択肢を問題文に挿入したもの,単語をレンマ化したもの,品詞情報が格納されています。このファイルは1レコードが12行で構成されています。

データの読み込み

  $handle = gzopen("base_contents.csv.gz","r");
  $id = 0;
  while($base_text = fgetcsv($handle)) {
    $text[$id] = $base_text;
    $id += 1;
  }
  fclose($handle);

問題文の方は,すべてのデータを一度に配列に格納します。

gzopen()はgz形式で圧縮されたファイルをオープンします。ファイルが大きくなると,そもそもサーバー側がアップロードを受け付けてくれなかったりするので,圧縮した上でアップロードしています。

fgetcsv()はcsvファイルからデータを1行ずつ読み込みます。これを配列$textに格納していきます。$textは二次元配列になります。

while()を使えば,すべての行についてデータを取り出すことができます。

$handle = gzopen("data.csv.gz","r");
  $id = 0;
  while($chars[0] = fgetcsv($handle)) {
    $chars[1] = fgetcsv($handle);
    $chars[2] = fgetcsv($handle);
    $chars[3] = fgetcsv($handle);
    $lemmas[0] = fgetcsv($handle);
    $lemmas[1] = fgetcsv($handle);
    $lemmas[2] = fgetcsv($handle);
    $lemmas[3] = fgetcsv($handle);
    $xposes[0] = fgetcsv($handle);
    $xposes[1] = fgetcsv($handle);
    $xposes[2] = fgetcsv($handle);
    $xposes[3] = fgetcsv($handle);

次に,単語$chars,レンマ$lemmas,品詞情報$xposesを読み込みます。

やり方は上と同じですが,1つのレコードにつき12行を読み込みます。

このようにwhile()でレコードを1つ分ずつ読み込んで,検索文字列との判定処理を行っていきます。

判定処理

      for ($i = 0; $i < $q_len; $i++) {
        $q_char = $q_chars[$i];
        //問題文の先頭から検索
        for ($j = 0; $j < $chars_len; $j++) {
          if ($seq == -1 || $j > $seq) {
            //品詞オプションあり
            if(preg_match('/\[.*\]/u',$q_char)) {
              $char = $xposes[$option][$j];
              if($q_char == '['.$char.']') {
                if($i == 0) {
                  $score_by_option[$option] += 100 - $j;
                  $seq = $j;
                break;
                } else {
                  $score_by_option[$option] += 100 - ($j - $seq)*10;
                  $seq = $j;
                break;
                }
              }

判定処理は単語ごとに行います。$iは入力された単語の順番を表しています。$jはレコードの単語の順番を表しています。これによって,入力された単語とレコードの単語を一つずつ判定していきます。

単語が [ ] で囲まれているときは品詞情報の配列 $xposes の要素と一致するかどうかで判定し,スコアを加算していきます。

同様に,単語が ( ) で囲まれているときはレンマの配列 $lemmas$,単語そのものの場合は単語の配列 $chars の要素と一致するかどうかを判定します。

修正により処理速度も大幅に改善しましたが,どうも文字コードを変換する mb_convert_encoding使っていたことが原因だったようです。今回は,csv ファイルを作成する時点で文字コードをUTF-8に統一するようにしたので,文字コード変換を省くことができました。

最後に,コード全体を示します。

<html lang="ja">
<head>
  <meta charset="utf-8">
  <title>英文法・語句選択問題検索</title>
  <!--boostrap-->
  <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css" integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous">
  <link rel="stylesheet" href="gsearch.css"  type="text/css">
  <!--[if lt IE 9]>
  <script src="http://html5shiv.googlecode.com/svn/trunk/html5.js"></script>
  <script src="http://css3-mediaqueries-js.googlecode.com/svn/trunk/css3-mediaqueries.js"></script>
  <![endif]-->
</head>
<body>
<?php
$ln1 = array("1.","1","①","A.","A","a.","a","ア");
$ln2 = array("2.","2","②","B.","B","b.","b","イ");
$ln3 = array("3.","3","③","C.","C","c.","c","ウ");
$ln4 = array("4.","4","④","D.","D","d.","d","エ");
$query = htmlspecialchars(@$_GET['search'], ENT_QUOTES, 'UTF-8');
$ln = @$_GET['label'];
$q_chars = explode(" ",$query);
$q_len = count($q_chars);
$text = [];
$chars = [];
$lemmas = [];
$xposes = [];
//検索結果の表示
function Gsearch_text_option() {
  global $text, $lemmas, $xposes, $ln, $ln1, $ln2, $ln3, $ln4;
  global $query, $q_chars, $q_len;
  $handle = gzopen("base_contents.csv.gz","r");
  $id = 0;
  while($base_text = fgetcsv($handle)) {
    $text[$id] = $base_text;
    $id += 1;
  }
  fclose($handle);
  $handle = gzopen("data.csv.gz","r");
  $id = 0;
  while($chars[0] = fgetcsv($handle)) {
    $chars[1] = fgetcsv($handle);
    $chars[2] = fgetcsv($handle);
    $chars[3] = fgetcsv($handle);
    $lemmas[0] = fgetcsv($handle);
    $lemmas[1] = fgetcsv($handle);
    $lemmas[2] = fgetcsv($handle);
    $lemmas[3] = fgetcsv($handle);
    $xposes[0] = fgetcsv($handle);
    $xposes[1] = fgetcsv($handle);
    $xposes[2] = fgetcsv($handle);
    $xposes[3] = fgetcsv($handle);
    for ($option = 0; $option <= 3; $option++) {
      $score_by_option = array(0,0,0,0);
      $chars_len = count($chars[$option]);
      $seq = -1;
      //検索文字列を単語ごとに判定
      for ($i = 0; $i < $q_len; $i++) {
        $q_char = $q_chars[$i];
        //問題文の先頭から検索
        for ($j = 0; $j < $chars_len; $j++) {
          if ($seq == -1 || $j > $seq) {
            //品詞オプションあり
            if(preg_match('/\[.*\]/u',$q_char)) {
              $char = $xposes[$option][$j];
              if($q_char == '['.$char.']') {
                if($i == 0) {
                  $score_by_option[$option] += 100 - $j;
                  $seq = $j;
                break;
                } else {
                  $score_by_option[$option] += 100 - ($j - $seq)*10;
                  $seq = $j;
                break;
                }
              }
            } elseif(preg_match('/\(.*\)/u',$q_char)) {
              //レンマオプションあり
              $char = $lemmas[$option][$j];
              if($q_char == '('.$char.')') {
                if($i == 0) {
                  $score_by_option[$option] += 100 - $j;
                  $seq = $j;
                break;
                } else {
                  $score_by_option[$option] += 100 - ($j - $seq)*10;
                  $seq = $j;
                break;
                }
              }
            } else {
              //通常の単語
              $char = $chars[$option][$j];
              if ($q_char == $char) {
                if($i == 0) {
                  $score_by_option[$option] += 100 - $j;
                  $seq = $j;
                break;
                } else {
                  $score_by_option[$option] += 100 - ($j - $seq)*10;
                  $seq = $j;
                break;
                }
              }
            }
          }
        }
        if ($score_by_option[$option] == 0) {
        break;
        }
      }
    }
    $scores[$id] = max($score_by_option);
    $id += 1;
  }
  fclose($handle);
  arsort($scores);
  $count = 0;
  echo "<p>検索結果:</p>";
  foreach($scores as $key => $value) {
    if($scores[$key] > 0) {
      echo "<p>";
      echo str_ireplace("#","(&emsp;)",$text[$key][0]);
      echo "&emsp;<span class='univ_name'>(".$text[$key][5].")</span>";
      echo "</p>";
      echo "<p style='text-indent:1.5em' class='mb-4'>";
      echo $ln1[$ln]." ".$text[$key][1];
      echo "&emsp;".$ln2[$ln]." ".$text[$key][2];
      echo "&emsp;".$ln3[$ln]." ".$text[$key][3];
      echo "&emsp;".$ln4[$ln]." ".$text[$key][4];
      echo "</p>";
      $count += 1;
    }
    if ($count == 100) {
      break;
    }
  }    
}
function Gsearch_option() {
  global $text, $chars, $xposes, $ln, $ln1, $ln2, $ln3, $ln4;
  global $query, $q_chars, $q_len;
  $count = 0;
  for ($id = 0; $id < count($text); $id++) {
    $score_min = 100;
    for($i=0;$i<=3;$i++){
      $score = levenshtein(mb_strtolower($query), mb_strtolower(substr($text[$id][$i],0,15)));
      if ($score < $score_min) {
        $score_min = $score;
      }
    }
    $scores[$id] = $score_min;
  }
  asort($scores);
  $count = 0;
  foreach($scores as $key => $value) {
    echo "<p class='mb-4'>";
    echo $ln1[$ln]." ".$text[$key][1];
    echo "&emsp;".$ln2[$ln]." ".$text[$key][2];
    echo "&emsp;".$ln3[$ln]." ".$text[$key][3];
    echo "&emsp;".$ln4[$ln]." ".$text[$key][4];
    echo "</p>";
    $count += 1;
    if ($count == 100) {
      break;
    }
  }      

}
?>  
<div class="container">
  <header class="mt-2">
  <p>
    <?php
    echo "英語・語句選択問題検索<span class='badge badge-success ml-2 mr-2'>ベータ版</span>データベース登録数10650問";
    ?>
  </p>
  </header>
  <article>
  <form method="get">
    <div class="form-row align-items-center">
      <div>
        <?php
        //検索ボックス
        echo '<input type="text" id="search_form" class="form-control mt-2 mb-2" name="search" size="33" maxlength="50"';
        echo 'value="'.htmlspecialchars(@$_GET['search'], ENT_QUOTES, 'UTF-8').'" autofocus>';
        ?>
      </div>
      <div class="col-auto">
        <input type="submit" class="btn btn-outline-info" value="検索">
      </div>
      <div class="btn-group btn-group-toggle ml-2" data-toggle="buttons">
        <?php
        //一つ目のトグルボタン
        $checked = array("","");
        $content = array("問題文と選択肢","選択肢のみ");
        if (@$_GET['display'] == null) {
          $checked[0] = "checked";
         } else {
          $checked[@$_GET['display']] = "checked";
        }
        for ($i = 0; $i < count($checked); $i++) {
          echo '<label class="btn btn-outline-info">';
          echo '<input type="radio" name="display" value="'.$i.'" '.$checked[$i].'>'.$content[$i];
          echo '</label>';
        }
        ?>
      </div>
      <div class="btn-group btn-group-toggle ml-2" data-toggle="buttons">
        <?php
        //二つ目のトグルボタン
        $checked = array("","","","","","","","");
        $content = array("1.","1","①","A.","A","a.","a","ア");
        if (@$_GET['label'] == null) {
          $checked[0] = "checked";
         } else {
          $checked[@$_GET['label']] = "checked";
        }
        for ($i = 0; $i < count($checked); $i++) {
          echo '<label class="btn btn-outline-info">';
          echo '<input type="radio" name="label" value="'.$i.'" '.$checked[$i].'>'.$content[$i];
          echo '</label>';
        }
        ?>
      </div>
    </div>
  </form>
  <div class="alert alert-success" role="alert">
    <p>品詞オプション</p>
    <hr>
    <p>[CC]等位接続詞&emsp;[IN]前置詞/従位接続詞&emsp;[JJ]形容詞&emsp;[JJR]形容詞比較級&emsp;[JJS]形容詞最上級&emsp;[MD]助動詞&emsp;[NOUN]名詞&emsp;[RB]副詞&emsp;[RBR]副詞比較級&emsp;[RBS]副詞最上級&emsp;[VBD]動詞過去形&emsp;[VBG]動名詞/現在分詞&emsp;[VBN]過去分詞&emsp;[VERB]動詞&emsp;[WDT]whatやwhichなど&emsp;[WP]関係代名詞&emsp;[WRB]関係副詞</p>
  </div>
  <div id="result" class="mt-4">
    <?php
      if (@$_GET['search'] != null && @$_GET['display'] == 0) {
        Gsearch_text_option(); #問題文と選択肢の検索
      }
      if (@$_GET['search'] != null && @$_GET['display'] == 1) {
        Gsearch_option(); #選択肢のみ検索
      }
    ?>
  </div>
</article>
<footer class="text-right mb-5">2020 <a href="https://mmsankosho.com">mm参考書</a></footer>
</div>
</body>
</html>