Using cosine-similarity to find “closeness” between consonants?

I am currently exploring this with ChatGPT, which is basically “how to represent and search/sort rhyming words in English”. To start the problem, I want to find out how similar certain consonant sequences are. Here are the current 29 feature categories I have for consonants so far.

alveolar
approximant
aspiration
bilabial
click
dental
dentalization
explosivity
flap
fricative
glottal
labialization
labiodental
labiovelar
lateral
length
nasal
nasalization
palatal
palatalization
pharyngealization
plosive
retroflex
sibilance
stop
tense
velar
velarization
voiced

Here is how I might map them in JS:

const features = getFeaturesList()

const b = makeData(features, { bilabial: true, voiced: true })
// bh (h is aspiration) in indian languages is common
const bh = makeData(features, { bilabial: true, voiced: true, aspiration: true })
const d = makeData(features, { dental: true, voiced: true })
const dh = makeData(features, { dental: true, voiced: true, aspiration: true })
const p = makeData(features, { bilabial: true })
const ph = makeData(features, { bilabial: true, aspiration: true })
const s = makeData(features, { fricative: true })
const z = makeData(features, { fricative: true, voiced: true })

logData(`b`, b)
logData(`bh`, bh)
logData(`d`, d)
logData(`dh`, dh)
logData(`p`, p)
logData(`ph`, ph)
logData(`s`, s)
logData(`z`, z)

logSimilarity(`b-bh`, b, bh)
logSimilarity(`b-d`, b, d)
logSimilarity(`b-dh`, b, dh)
logSimilarity(`b-p`, b, p)
logSimilarity(`b-ph`, b, ph)
logSimilarity(`b-s`, b, s)
logSimilarity(`b-z`, b, z)

function getFeaturesList() {
  return {
    bilabial: {
      true: [1],
      false: [0],
    },
    labiodental: {
      true: [1],
      false: [0],
    },
    dental: {
      true: [1],
      false: [0],
    },
    alveolar: {
      true: [1],
      false: [0],
    },
    retroflex: {
      true: [1],
      false: [0],
    },
    palatal: {
      true: [1],
      false: [0],
    },
    velar: {
      true: [1],
      false: [0],
    },
    labiovelar: {
      true: [1],
      false: [0],
    },
    glottal: {
      true: [1],
      false: [0],
    },
    nasal: {
      true: [1],
      false: [0],
    },
    fricative: {
      true: [1],
      false: [0],
    },
    approximant: {
      true: [1],
      false: [0],
    },
    flap: { // r
      true: [1],
      false: [0],
    },
    lateral: {
      true: [1],
      false: [0],
    },
    aspiration: {
      true: [1],
      false: [0],
    },
    click: {
      true: [1],
      false: [0],
    },
    dentalization: {
      true: [1],
      false: [0],
    },
    explosivity: {
      in: [1, 0],
      out: [0, 1],
      false: [0, 0],
    },
    plosive: {
      true: [1],
      false: [0],
    },
    labialization: {
      true: [1],
      false: [0],
    },
    nasalization: {
      true: [1],
      false: [0],
    },
    palatalization: {
      true: [1],
      false: [0],
    },
    pharyngealization: {
      true: [1],
      false: [0],
    },
    stop: {
      true: [1],
      false: [0],
    },
    tense: {
      true: [1],
      false: [0],
    },
    velarization: {
      true: [1],
      false: [0],
    },
    voiced: {
      true: [1],
      false: [0],
    },
    sibilance: {
      true: [1],
      false: [0],
    },
    length: {
      true: [1],
      false: [0],
    },
  }
}

function cosineSimilarity(v1, v2) {
  let dotProduct = 0
  let normA = 0
  let normB = 0
  for (let i = 0; i < v1.length; i++) {
    dotProduct += v1[i] * v2[i]
    normA += Math.pow(v1[i], 2)
    normB += Math.pow(v2[i], 2)
  }
  return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB))
}

function makeData(features, mappings) {
  const featureNames = getSortedFeatureNames()
  const vector = new Array()
  const map = {}
  featureNames.forEach(name => {
    if (mappings[name]) {
      if (!features[name][mappings[name]]) {
        throw new Error(`Missing feature ${name} value ${mappings[name]}.`)
      }
    }
    const provided = features[name][mappings[name]]
    const fallback = features[name].false
    const slice = provided ?? fallback
    vector.push(...slice)

    if (provided && provided !== fallback) {
      map[name] = mappings[name]
    }
  })
  return { map, vector }
}

function getSortedFeatureNames() {
  return [
    'alveolar',
    'approximant',
    'aspiration',
    'bilabial',
    'click',
    'dental',
    'dentalization',
    'explosivity',
    'flap',
    'fricative',
    'glottal',
    'labialization',
    'labiodental',
    'labiovelar',
    'lateral',
    'length',
    'nasal',
    'nasalization',
    'palatal',
    'palatalization',
    'pharyngealization',
    'plosive',
    'retroflex',
    'sibilance',
    'stop',
    'tense',
    'velar',
    'velarization',
    'voiced',
  ]
}

function logSimilarity(key, a, b) {
  console.log(key, cosineSimilarity(a.vector, b.vector))
}

function logData(key, data) {
  console.log(key.padEnd(4, ' '), data.vector.join(''))
  console.log(`  ${JSON.stringify(data.map)}`)
}

That prints this for the vectors/maps:

b    000100000000000000000000000001
  {"bilabial":true,"voiced":true}
bh   001100000000000000000000000001
  {"aspiration":true,"bilabial":true,"voiced":true}
d    000001000000000000000000000001
  {"dental":true,"voiced":true}
dh   001001000000000000000000000001
  {"aspiration":true,"dental":true,"voiced":true}
p    000100000000000000000000000000
  {"bilabial":true}
ph   001100000000000000000000000000
  {"aspiration":true,"bilabial":true}
s    000000000010000000000000000000
  {"fricative":true}
z    000000000010000000000000000001
  {"fricative":true,"voiced":true}

And this is b compared to a few other consonant sounds:

b-bh 0.8164965809277259
b-d 0.4999999999999999
b-dh 0.40824829046386296
b-p 0.7071067811865475
b-ph 0.4999999999999999
b-s 0
b-z 0.4999999999999999

Everything looks decent except b-ph should be closer to b-p, and b-s/b-z should be closer together, maybe like 0.1 or something.

What am I supposed to do from here to compare every possible consonant sound with every other? (Assuming I create the corresponding “feature vector” mapping manually, like I did for these few examples…). How do I make it so similar sounds get a higher score? What manual “supervised learning” sort of stuff do I need to do here? Just in comparing the few hundreds or probably less than 2,000 consonant pairs, that’s all I need to do. Do I need to add more features or something? Or what do I do from here, to get higher “accuracy”.

Basically, what are the high-level rough steps involved in making this work?

Accuracy is defined as “similar consonants are close together”. Similar consonants which are close together is somewhat subjective, but perhaps I could do something to map my subjective interpretation into vectors? How can I do that basically? That is the crux of this question.

Note: The long-term goal in going down this road is to create a system for comparing words which have a “rhyme similarity”. Still a ways to go to get there.

Note 2: We don’t necessarily need to use “cosine similarity” if it’s not a good fit, can use any distance function which makes sense.

Note 3: This is my first foray into implementing anything “AI” related, I have a very basic understanding of how vectors / n-dimensional “features spaces” work at this point, but I would like to learn how to implement a more robust AI/NLP solution to this down the road :).

Note 4: ChatGPT suggests I manually fine-tune feature vectors, (between every consonant pair!!!). That would be a huge amount of work it seems, but is that the basic approach?