命令行錯誤提示—談談模糊集

在開發的過程當中,咱們會使用各類指令。有時候,咱們因爲這樣或者那樣的緣由,寫錯了某些指令。此時,應用程序每每會爆出錯誤。css

Unrecognized option 'xxx' (did you mean 'xxy'?)

能夠看到,當前代碼不只僅提示了當前你輸入的配置錯誤。同時還提供了相似當前輸入的近似匹配指令。很是的智能。此時,咱們須要使用算法來計算,即模糊集。git

事實上,模糊集其實能夠解決一些現實的問題。例如咱們有一個「高個子」集合 A,定義 1.75m 爲高個子。那麼在通用邏輯中咱們會認爲某一個元素隸屬或者不隸屬該集合。也就是 1.78 就是高個子,而 1.749 就不是高個子,即便它距離 1.75 米只差裏一毫米。該集合被稱爲(two-valued 二元集),與此相對的,模糊集合則沒有這種問題。github

在模糊集合中,全部人都是集合 A 的成員,所不一樣的僅僅是匹配度而已。咱們能夠經過計算匹配度來決定差別性。算法

如何運行

言歸正轉,咱們回到當前實現。對於模糊集的實現,咱們能夠參考 fuzzyset.js (注: 該庫須要商業許可) 和 fuzzyset.js 交互式文檔 進行學習。數組

在這裏,我僅僅只介紹基本算法,至於數據存儲和優化在完整實現中。緩存

經過查看交互式文檔,咱們能夠算法是經過餘弦類似度公式去計算。框架

在直角座標系中,類似度公式如此計算。函數

cos = (a b) / (|a| |b| ). => 等同於

( (x1, y1) (x2,y2)) / (Math.sqrt(x1 2 + y1 2) Math.sqrt(x2 2 + y2 2))oop

而類似度公式是經過將字符串轉化爲數字矢量來計算。若是當前的字符串分別爲 「smaller」 和 「smeller」。咱們須要分解字符串子串來計算。學習

當前能夠分解的字符串子串能夠根據項目來自行調整,簡單起見,咱們這裏使用 2 爲單位。

兩個字符串能夠被分解爲:

const smallSplit: string[] = [
  '-s',
  'sm',
  'ma',
  'al',
  'll',
  'l-'
]
const smelllSplit: string[] = [
  '-s',
  'sm',
  'me',
  'el',
  'll',
  'll',
  'l-'
]

咱們能夠根據當前把代碼變爲以下向量:

const smallGramCount = {
  '-s': 1,
  'sm': 1,
  'ma': 1,
  'al': 1,
  'll': 1,
  'l-': 1
}

const smallGramCount = {
  '-s': 1,
  'sm': 1,
  'me': 1,
  'el': 1,
  'll': 2,
  'l-': 1
}
const _nonWordRe = /[^a-zA-Z0-9\u00C0-\u00FF, ]+/g;

/**
 * 能夠直接把 'bal' 變爲 ['-b', 'ba', 'al', 'l-']
 */
function iterateGrams (value: string, gramSize: number = 2) {
  // 當前 數值添加先後綴 '-'
  const simplified = '-' + value.toLowerCase().replace(_nonWordRe, '') + '-'

  // 經過計算當前子字符串長度和當前輸入數據長度的差值
  const lenDiff = gramSize - simplified.length

  // 結果數組
  const results = []

  // 若是當前輸入的數據長度小於當前長度
  // 直接添加 「-」 補差計算
  if (lenDiff > 0) {
    for (var i = 0; i < lenDiff; ++i) {
      value += '-';
    }
  }

  // 循環截取數值而且塞入結果數組中
  for (var i = 0; i < simplified.length - gramSize + 1; ++i) {
    results.push(simplified.slice(i, i + gramSize));
  }
  return results;
}

/**
 * 能夠直接把 ['-b', 'ba', 'al', 'l-'] 變爲 {-b: 1, 'ba': 1, 'al': 1, 'l-': 1}
 */
function gramCounter(value: string, gramSize: number = 2) {
  const result = {}
  // 根據當前的
  const grams = _iterateGrams(value, gramSize)
  for (let i = 0; i < grams.length; ++i) {
    // 根據當前是否有數據來進行數據增長和初始化 1
    if (grams[i] in result) {
      result[grams[i]] += 1;
    } else {
      result[grams[i]] = 1;
    }
  }
  return result;
}

而後咱們能夠計算 small \* smell 爲:

small gram small count smell gram smell gram
-s 1 \* -s 1
sm 1 \* sm 1
ma 1 \* ma 0
me 0 \* me 1
al 1 \* al 0
el 0 \* el 1
ll 1 \* ll 1
l- 1 \* l- 1
sum 4
function calcVectorNormal() {
  // 獲取向量對象
  const small_counts = gramCounter('small', 2)
  const smell_counts = gramCOunter('smell', 2)

  // 使用 set 進行字符串過濾
  const keySet = new Set()

  // 把兩單詞組共有的字符串塞入 keySet
  for (let key in small_counts) {
    keySet.add(key)
  }

  for (let key in smell_counts) {
    keySet.add(key)
  }

  let sum: number = 0

  // 計算 small * smell
  for(let key in keySet.keys()) {
    sum += (small_count[key] ?? 0) * (smell_count[key] ?? 0)
  }

  return sum
}

同時咱們能夠計算 |small|\*|smell| 爲:

small Gram SmAll Count Count \\ 2
-s 1 1
sm 1 1
ma 1 1
al 1 1
ll 1 1
l- 1 1
sum 6
sqrt 2.449

同理可得當前 smell sqrt 也是 2.449。

最終的計算爲: 4 / (2.449 \* 2.449) = 0.66 。

計算方式爲

// ... 上述代碼

function calcVectorNormal() {
  // 獲取向量對象
  const gram_counts = gramCounter(normalized_value, 2);
  // 計算
  let sum_of_square_gram_counts = 0;
  let gram;
  let gram_count;

  for (gram in gram_counts) {
    gram_count = gram_counts[gram];
    // 乘方相加
    sum_of_square_gram_counts += Math.pow(gram_count, 2);
  }

  return Math.sqrt(sum_of_square_gram_counts);
}

則 small 與 smell 在子字符串爲 2 狀況下匹配度爲 0.66。

固然,咱們看到開頭和結束添加了 - 也做爲標識符號,該標識是爲了識別出 sell 與 llse 之間的不一樣,若是使用

const sellSplit = [
  '-s',
  'se',
  'el',
  'll',
  'l-'
]
const llseSplit = [
  '-l',
  'll',
  'ls',
  'se',
  'e-'
]

咱們能夠看到當前的類似的只有 'll' 和 'se' 兩個子字符串。

完整代碼

編譯型框架 svelte 項目代碼中用到此功能,使用代碼解析以下:

const valid_options = [
  'format',
  'name',
  'filename',
  'generate',
  'outputFilename',
  'cssOutputFilename',
  'sveltePath',
  'dev',
  'accessors',
  'immutable',
  'hydratable',
  'legacy',
  'customElement',
  'tag',
  'css',
  'loopGuardTimeout',
  'preserveComments',
  'preserveWhitespace'
];

// 若是當前操做不在驗證項中,纔會進行模糊匹配
if (!valid_options.includes(key)) {
  // 匹配後返回 match 或者 null
  const match = fuzzymatch(key, valid_options);
  let message = `Unrecognized option '${key}'`;
  if (match) message += ` (did you mean '${match}'?)`;

  throw new Error(message);
}

實現代碼以下所示:

export default function fuzzymatch(name: string, names: string[]) {
  // 根據當前已有數據創建模糊集,若是有字符須要進行匹配、則能夠對對象進行緩存
  const set = new FuzzySet(names);
  // 獲取當前的匹配
  const matches = set.get(name);
  // 若是有匹配項,且匹配度大於 0.7,返回匹配單詞,不然返回 null
  return matches && matches[0] && matches[0][0] > 0.7 ? matches[0][1] : null;
}

// adapted from https://github.com/Glench/fuzzyset.js/blob/master/lib/fuzzyset.js
// BSD Licensed

// 最小子字符串 2
const GRAM_SIZE_LOWER = 2;
// 最大子字符串 3
const GRAM_SIZE_UPPER = 3;

//  進行 Levenshtein 計算,更適合輸入完整單詞的匹配
function _distance(str1: string, str2: string) {
  if (str1 === null && str2 === null)
    throw 'Trying to compare two null values';
  if (str1 === null || str2 === null) return 0;
  str1 = String(str1);
  str2 = String(str2);

  const distance = levenshtein(str1, str2);
  if (str1.length > str2.length) {
    return 1 - distance / str1.length;
  } else {
    return 1 - distance / str2.length;
  }
}

// Levenshtein距離,是指兩個字串之間,由一個轉成另外一個所需的最少的編輯操做次數。
function levenshtein(str1: string, str2: string) {
  const current: number[] = [];
  let prev;
  let value;

  for (let i = 0; i <= str2.length; i++) {
    for (let j = 0; j <= str1.length; j++) {
      if (i && j) {
        if (str1.charAt(j - 1) === str2.charAt(i - 1)) {
          value = prev;
        } else {
          value = Math.min(current[j], current[j - 1], prev) + 1;
        }
      } else {
        value = i + j;
      }
      prev = current[j];
      current[j] = value;
    }
  }
  return current.pop();
}

// 正則匹配除單詞 字母 數字以及逗號和空格外的數據
const non_word_regex = /[^\w, ]+/;

// 上述代碼已經介紹
function iterate_grams(value: string, gram_size = 2) {
  const simplified = '-' + value.toLowerCase().replace(non_word_regex, '') + '-';
  const len_diff = gram_size - simplified.length;
  const results = [];

  if (len_diff > 0) {
    for (let i = 0; i < len_diff; ++i) {
      value += '-';
    }
  }
  for (let i = 0; i < simplified.length - gram_size + 1; ++i) {
    results.push(simplified.slice(i, i + gram_size));
  }
  return results;
}

// 計算向量,上述代碼已經介紹
function gram_counter(value: string, gram_size = 2) {
  const result = {};
  const grams = iterate_grams(value, gram_size);
  let i = 0;

  for (i; i < grams.length; ++i) {
    if (grams[i] in result) {
      result[grams[i]] += 1;
    } else {
      result[grams[i]] = 1;
    }
  }
  return result;
}

// 排序函數
function sort_descending(a, b) {
  return b[0] - a[0];
}

class FuzzySet {
  // 數據集合,記錄全部的可選項目
  // 1.優化初始化時候,相同的可選項數據,同時避免屢次計算相同向量
  // 2.當前輸入的值與可選項相等,直接返回,無需計算
  exact_set = {};
  // 匹配對象存入,存儲全部單詞的向量
  // 如 match_dist['ba'] = [
  //     第2個單詞,有 3 個
  //     {3, 1}
  //     第5個單詞,有 2 個
  //     {2, 4}
  //   ]
  //   後面單詞匹配時候,能夠根據單詞索引進行匹配而後計算最終分數
  match_dict = {};
  // 根據不一樣子字符串獲取不一樣的單詞向量,最終有不一樣的匹配度
  // item[2] = [[2.6457513110645907, "aaab"]]
  items = {};

  constructor(arr: string[]) {
    // 當前選擇 2 和 3 爲子字符串匹配
    // item = {2: [], 3: []}
    for (let i = GRAM_SIZE_LOWER; i < GRAM_SIZE_UPPER + 1; ++i) {
      this.items[i] = [];
    }

    // 添加數組
    for (let i = 0; i < arr.length; ++i) {
      this.add(arr[i]);
    }
  }

  add(value: string) {
    const normalized_value = value.toLowerCase();

    // 若是當前單詞已經計算,直接返回
    if (normalized_value in this.exact_set) {
      return false;
    }

    // 分別計算 2 和 3 的向量
    for (let i = GRAM_SIZE_LOWER; i < GRAM_SIZE_UPPER + 1; ++i) {
      this._add(value, i);
    }
  }

  _add(value: string, gram_size: number) {
    const normalized_value = value.toLowerCase();
    // 獲取 items[2]
    const items = this.items[gram_size] || [];
    // 獲取數組的長度做爲索引
    const index = items.length;

    // 沒有看出有實際的用處?實驗也沒有什麼做用?不會影響
    items.push(0);

    // 獲取 向量數據
    const gram_counts = gram_counter(normalized_value, gram_size);
    let sum_of_square_gram_counts = 0;
    let gram;
    let gram_count;

    // 同上述代碼,只不過把全部的匹配項目和當前索引都加入 match_dict 中去
    // 如 this.match_dict['aq'] = [[1, 2], [3,3]]
    for (gram in gram_counts) {
      gram_count = gram_counts[gram];
      sum_of_square_gram_counts += Math.pow(gram_count, 2);
      if (gram in this.match_dict) {
        this.match_dict[gram].push([index, gram_count]);
      } else {
        this.match_dict[gram] = [[index, gram_count]];
      }
    }
    const vector_normal = Math.sqrt(sum_of_square_gram_counts);
    // 添加向量  如: this.items[2][3] = [4.323, 'sqaaaa']
    items[index] = [vector_normal, normalized_value];
    this.items[gram_size] = items;
    // 設置當前小寫字母,優化代碼
    this.exact_set[normalized_value] = value;
  }

  // 輸入當前值,獲取選擇項
  get(value: string) {
    const normalized_value = value.toLowerCase();
    const result = this.exact_set[normalized_value];

    // 若是當前值徹底匹配,直接返回 1,沒必要計算
    if (result) {
      return [[1, result]];
    }

    let results = [];
    // 從多到少,若是多子字符串沒有結果,轉到較小的大小
    for (
      let gram_size = GRAM_SIZE_UPPER;
      gram_size >= GRAM_SIZE_LOWER;
      --gram_size
    ) {
      results = this.__get(value, gram_size);
      if (results) {
        return results;
      }
    }
    return null;
  }

  __get(value: string, gram_size: number) {
    const normalized_value = value.toLowerCase();
    const matches = {};
    // 得到當前值的向量值
    const gram_counts = gram_counter(normalized_value, gram_size);
    const items = this.items[gram_size];
    let sum_of_square_gram_counts = 0;
    let gram;
    let gram_count;
    let i;
    let index;
    let other_gram_count;

    // 計算獲得較爲匹配的數據
    for (gram in gram_counts) {
      // 獲取 向量單詞用於計算
      gram_count = gram_counts[gram];
      sum_of_square_gram_counts += Math.pow(gram_count, 2);
      // 取得當前匹配的 [index, gram_count]
      if (gram in this.match_dict) {
        // 獲取全部匹配當前向量單詞的項目,而且根據 索引加入 matches
        for (i = 0; i < this.match_dict[gram].length; ++i) {
          // 得到當前匹配的索引 === 輸入單詞[index]
          index = this.match_dict[gram][i][0];
          // 得到匹配子字符串的值
          other_gram_count = this.match_dict[gram][i][1];
          // 單詞索引添加,注:只要和當前子字符串匹配的 索引都會加入 matches
          if (index in matches) {
            matches[index] += gram_count * other_gram_count;
          } else {
            matches[index] = gram_count * other_gram_count;
          }
        }
      }
    }


    const vector_normal = Math.sqrt(sum_of_square_gram_counts);
    let results = [];
    let match_score;

    // 構建最終結果 [分數, 單詞]
    for (const match_index in matches) {
      match_score = matches[match_index];
      results.push([
        // 分數
        match_score / (vector_normal * items[match_index][0]),
        // 單詞
        items[match_index][1]
      ]);
    }

    // 雖然全部的與之匹配子字符串都會進入,但咱們只須要最高的分數
    results.sort(sort_descending);

    let new_results = [];

    // 若是匹配數目很大,只取的前 50 個數據進行計算
    const end_index = Math.min(50, results.length);

    // 因爲是字符類型數據,根據當前數據在此計算 levenshtein  距離
    for (let i = 0; i < end_index; ++i) {
      new_results.push([
        _distance(results[i][1], normalized_value), results[i][1]
      ]);
    }
    results = new_results;
    // 在此排序
    results.sort(sort_descending);

    new_results = [];
    for (let i = 0; i < results.length; ++i) {
      // 由於 第一的分數是最高的,全部後面的數據若是等於第一個
      // 也能夠進入最終選擇
      if (results[i][0] == results[0][0]) {
        new_results.push([results[i][0], this.exact_set[results[i][1]]]);
      }
    }

    // 返回最終結果
    return new_results;
  }
}

鼓勵一下

若是你以爲這篇文章不錯,但願能夠給與我一些鼓勵,在個人 github 博客下幫忙 star 一下。

博客地址

參考資料

fuzzyset.js 交互式文檔

svelte fuzzymatch

相關文章
相關標籤/搜索