DEV Community

Cover image for 乱雑なWebデータのための型推論システムの構築
circobit
circobit

Posted on

乱雑なWebデータのための型推論システムの構築

Webテーブルはすべて文字列です。何もかもが文字列。しかしJSONやSQLにエクスポートする際には、こうしたいはずです:

  • "1,234.56"1234.56(数値)
  • "2024-03-15" → 日付型
  • "Yes"true(ブーリアン)
  • "N/A"null

信頼できる型推論システムを構築するには、現実のデータの混沌に対処する必要があります。HTML Table Exporterでの実装アプローチを紹介します。

課題:曖昧性だらけ

以下の値を考えてみてください:

可能性...
"1,234" 数値1234(米国)または1.234(欧州)
"01/02/03" 2003年1月2日(米国)、2003年2月1日(欧州)、2001年2月3日
"1" 整数1またはブーリアンtrue
"N/A" 文字列またはnull
"0" 整数0またはブーリアンfalse

型推論はパースの問題ではなく、曖昧な証拠から意図を推測する問題です。

アーキテクチャ:カラムレベルの推論

セルごとに型を推論するのは信頼性に欠けます。"1" 単体では何にでもなりえます。しかし ["1", "2", "3", "4"] を含むカラムは明らかに整数です。

私のアプローチ:

  1. カラムから値をサンプリング(最大100件)
  2. 各非null値の型を推論
  3. 集計してカラム型を決定
  4. 90%の閾値を適用——90%以上の値が型に一致すれば、その型を使用
const DATA_TYPES = {
  STRING: "string",
  INTEGER: "integer", 
  NUMBER: "number",
  BOOLEAN: "boolean",
  DATE: "date",
  NULL: "null",
};

function inferColumnType(columnValues, maxSamples = 100) {
  const nonNullValues = columnValues.filter(v => v != null && v !== "");

  if (nonNullValues.length === 0) {
    return { type: DATA_TYPES.STRING, confidence: 0 };
  }

  const sample = nonNullValues.slice(0, maxSamples);

  // 各型の出現回数をカウント
  const typeCounts = {
    [DATA_TYPES.INTEGER]: 0,
    [DATA_TYPES.NUMBER]: 0,
    [DATA_TYPES.BOOLEAN]: 0,
    [DATA_TYPES.DATE]: 0,
    [DATA_TYPES.STRING]: 0,
  };

  for (const val of sample) {
    const type = inferValueType(val);
    if (type === DATA_TYPES.INTEGER) {
      // INTEGERはNUMBERのサブセット
      typeCounts[DATA_TYPES.INTEGER]++;
      typeCounts[DATA_TYPES.NUMBER]++;
    } else if (type !== DATA_TYPES.NULL) {
      typeCounts[type]++;
    }
  }

  const total = sample.length;
  const threshold = 0.9;

  // 優先順位で型をチェック
  if (typeCounts[DATA_TYPES.BOOLEAN] >= total * threshold) {
    return { type: DATA_TYPES.BOOLEAN, confidence: typeCounts[DATA_TYPES.BOOLEAN] / total };
  }
  if (typeCounts[DATA_TYPES.DATE] >= total * threshold) {
    return { type: DATA_TYPES.DATE, confidence: typeCounts[DATA_TYPES.DATE] / total };
  }
  if (typeCounts[DATA_TYPES.INTEGER] >= total * threshold) {
    return { type: DATA_TYPES.INTEGER, confidence: typeCounts[DATA_TYPES.INTEGER] / total };
  }
  if (typeCounts[DATA_TYPES.NUMBER] >= total * threshold) {
    return { type: DATA_TYPES.NUMBER, confidence: typeCounts[DATA_TYPES.NUMBER] / total };
  }

  return { type: DATA_TYPES.STRING, confidence: 1 };
}
Enter fullscreen mode Exit fullscreen mode

なぜ90%? 実データにはノイズがあります。100個の整数のカラムに1つの "N/A" があるかもしれません。100%一致を要求するのは厳しすぎます。

値レベルの型検出

inferValueType 関数で個々の値を処理します:

function inferValueType(value) {
  if (value == null || value === "") {
    return DATA_TYPES.NULL;
  }

  const str = String(value).trim();
  if (str === "") return DATA_TYPES.NULL;

  // ブーリアンチェック
  const lowerStr = str.toLowerCase();
  if (["true", "false", "yes", "no", "", "si"].includes(lowerStr)) {
    return DATA_TYPES.BOOLEAN;
  }

  // 日付チェック(ISO形式優先)
  if (/^\d{4}-\d{2}-\d{2}/.test(str)) {
    return DATA_TYPES.DATE;
  }
  if (/^\d{1,2}[/\-\.]\d{1,2}[/\-\.]\d{2,4}$/.test(str)) {
    return DATA_TYPES.DATE;
  }

  // 整数チェック(厳密)
  if (/^-?\d+$/.test(str)) {
    return DATA_TYPES.INTEGER;
  }

  // 数値チェック(小数点・区切り文字あり)
  const cleanedForNumber = str
    .replace(/[$€£¥%\s]/g, "")
    .replace(/,/g, ".");

  if (/^-?\d+(\.\d+)?$/.test(cleanedForNumber)) {
    return DATA_TYPES.NUMBER;
  }

  return DATA_TYPES.STRING;
}
Enter fullscreen mode Exit fullscreen mode

数値正規化の難題

欧州 vs 米国の数値フォーマットは最も難しい曖昧性です:

米国での解釈 欧州での解釈
"1,234" 1234 1.234
"1.234" 1.234 1234
"1,234.56" 1234.56 無効
"1.234,56" 無効 1234.56

フォーマット検出のヒューリスティック:

function normalizeNumber(value) {
  if (value == null) return value;

  let str = String(value).trim();

  // 通貨記号と空白を除去
  str = str.replace(/[$€£¥\s]/g, "");

  // パーセンテージ処理
  const isPercent = str.endsWith("%");
  if (isPercent) str = str.slice(0, -1);

  // セパレーター分析によるフォーマット検出
  const commaCount = (str.match(/,/g) || []).length;
  const dotCount = (str.match(/\./g) || []).length;
  const lastComma = str.lastIndexOf(",");
  const lastDot = str.lastIndexOf(".");

  let normalized;

  if (commaCount === 0 && dotCount === 0) {
    normalized = str;
  } else if (commaCount === 0 && dotCount === 1) {
    const afterDot = str.slice(lastDot + 1);
    if (afterDot.length === 3 && /^\d+$/.test(afterDot)) {
      normalized = str.replace(".", "");
    } else {
      normalized = str;
    }
  } else if (commaCount === 1 && dotCount === 0) {
    const afterComma = str.slice(lastComma + 1);
    if (afterComma.length === 3 && /^\d+$/.test(afterComma)) {
      normalized = str.replace(",", "");
    } else {
      normalized = str.replace(",", ".");
    }
  } else if (lastDot > lastComma) {
    normalized = str.replace(/,/g, "");
  } else if (lastComma > lastDot) {
    normalized = str.replace(/\./g, "").replace(",", ".");
  } else {
    return value;
  }

  const num = parseFloat(normalized);
  if (Number.isNaN(num)) return value;

  return isPercent ? num / 100 : num;
}
Enter fullscreen mode Exit fullscreen mode

重要なポイント: セパレーターの位置と数でほとんどの曖昧性が解消されます。最後のセパレーターが通常は小数点セパレーターです。

ブーリアン検出(落とし穴あり)

明白なブーリアン: "true", "false", "yes", "no"

しかし "0""1" はどうでしょう?

// 問題:税率がブーリアンに変換される
// カラム: [0, 0.05, 0.1, 0.2]  -> [false, 0.05, 0.1, 0.2] 
Enter fullscreen mode Exit fullscreen mode

解決策:純粋な数値をブーリアンに変換しない。

function applyBooleanNormalization(rows, booleanConfig) {
  const trueValues = new Set(booleanConfig.true.map(v => v.toLowerCase()));
  const falseValues = new Set(booleanConfig.false.map(v => v.toLowerCase()));

  return rows.map((row, rowIndex) => {
    if (rowIndex === 0) return row; // ヘッダーをスキップ

    return row.map(cell => {
      if (cell == null) return cell;

      const cellStr = String(cell).toLowerCase().trim();

      // 純粋な数値はスキップ(数値カラムで0→falseを防ぐ)
      if (/^-?\d+(\.\d+)?$/.test(cellStr)) {
        return cell;
      }

      if (trueValues.has(cellStr)) return "true";
      if (falseValues.has(cellStr)) return "false";

      return cell;
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Null値の処理

Webデータは「値なし」を様々な方法で表現します:

  • "" (空文字列)
  • "N/A", "n/a", "NA"
  • "-", "--"
  • "null", "NULL"
  • "none", "None"
  • "." (政府データで見られる)

設定可能なnull検出:

function applyNullValues(rows, nullPatterns) {
  const nullSet = new Set(nullPatterns.map(v => v.toLowerCase().trim()));

  return rows.map((row, rowIndex) => {
    if (rowIndex === 0) return row; // ヘッダーをスキップ

    return row.map(cell => {
      if (cell == null) return null;

      const cellStr = String(cell).toLowerCase().trim();
      return nullSet.has(cellStr) ? null : cell;
    });
  });
}

// 使用例
applyNullValues(rows, ["N/A", "n/a", "-", "--", "null", "none", "."]);
Enter fullscreen mode Exit fullscreen mode

パイプライン

型推論とクリーニングの完全パイプライン:

function cleanTable(tableInfo, config) {
  let rows = cloneRows(tableInfo.rows);

  // 1. 全文字列をトリム
  if (config.trimStrings) {
    rows = applyTrimStrings(rows);
  }

  // 2. nullパターンを実際のnullに変換
  if (config.nullValues?.length) {
    rows = applyNullValues(rows, config.nullValues);
  }

  // 3. ブーリアンを正規化(数値の前に。0→false問題を回避)
  if (config.booleans) {
    rows = applyBooleanNormalization(rows, config.booleans);
  }

  // 4. 数値を正規化(EU/USフォーマット対応)
  if (config.normalizeNumbers) {
    rows = applyNumberNormalization(rows);
  }

  // 5. 日付を正規化(オプション、フォーマット指定)
  if (config.normalizeDates) {
    rows = applyDateNormalization(rows, config.dateFormat);
  }

  return { ...tableInfo, rows };
}
Enter fullscreen mode Exit fullscreen mode

エッジケースのテスト

型推論にはエッジケースが多数あります。テストスイートを維持しています:

// 数値正規化テスト
assertEqual(normalizeNumber("1,234.56"), 1234.56);    // 米国フォーマット
assertEqual(normalizeNumber("1.234,56"), 1234.56);    // 欧州フォーマット
assertEqual(normalizeNumber("€1.234,56"), 1234.56);   // 通貨付き
assertEqual(normalizeNumber("45,5%"), 0.455);         // パーセンテージ
assertEqual(normalizeNumber("1.234"), 1234);          // 欧州の千の位
assertEqual(normalizeNumber("1.23"), 1.23);           // 小数

// ブーリアンテスト
assertEqual(inferValueType("Yes"), DATA_TYPES.BOOLEAN);
assertEqual(inferValueType("0"), DATA_TYPES.INTEGER);  // ブーリアンではない
assertEqual(inferValueType("0.5"), DATA_TYPES.NUMBER); // ブーリアンではない
Enter fullscreen mode Exit fullscreen mode

学んだこと

  1. カラムのコンテキストはセル分析に勝る。 単独の "1" は曖昧。整数のカラムは明確。

  2. 変換は保守的に。 誤った変換でデータを破壊するより、文字列のまま残す方がよい。

  3. 設定可能にする。 ドメインによってnull値、ブーリアン表現、数値フォーマットは異なる。

  4. 実データでテスト。 合成テストでは実際のWebテーブルの混沌を見逃す。

このシステムはHTML Table Exporterのデータクリーニングを支えています。これらの課題の実例として、テーブルをExcelにコピーするのに最適なChrome拡張機能で、適切な型推論がどのようにフォーマット崩れを修正するかをご覧ください。

PRO版ではユーザーがエクスポートプロファイルごとにこれらのルールを設定できます。詳しくは gauchogrid.com/ja/html-table-exporter をご覧いただくか、Chrome Web Store でお試しください。


別のドメインで型推論を構築中ですか?エッジケースについてぜひお聞かせください。

Top comments (0)