Lance Pollard
Lance Pollard

Reputation: 79450

What's going wrong in these weighted jaccard sum calculations for comparing the pronunciation of consonant clusters?

Context

I have this code for my attempt to create a "similarity mapping" between consonants (or consonant clusters), to the same set of consonants/clusters (basically a cross product mapping), like this form:

// ... all possible consonants/clusters ...
type ClusterKey = 'b' | 'c' | 'd' | 'f' | 'br' | 'bl'

type ClusterMappings = Record<ClusterKey, ClusterMapping>

type ClusterMapping = Record<string, number>

The ClusterKey values are basically the keys in this object:

export const CONSONANTS: Record<string, Float32Array> = {
  m: consonant({ bilabial: 0.9, nasal: 1.0 }),
  N: consonant({ retroflex: 1.0, nasal: 0.8 }),
  n: consonant({ alveolar: 1.0, nasal: 0.9 }),
  q: consonant({ velar: 1.0, nasal: 0.8 }),
  'G~': consonant({ velarization: 1.0 }),
  G: consonant({ velar: 1.0, fricative: 0.8 }),
  'g?': consonant({ plosive: 1.0, velar: 1.0, implosive: 0.5 }),
  g: consonant({ plosive: 1.0, velar: 1.0 }),
  "'": consonant({ plosive: 1.0, glottal: 0.9 }),
  Q: consonant({ pharyngeal: 1.0, fricative: 0.9 }),
  'd?': consonant({ dental: 1.0, plosive: 1.0, implosive: 0.5 }),
  'd!': consonant({ dental: 1.0, plosive: 1.0, ejective: 0.5 }),
  'd*': consonant({ click: 1.0 }),
  ...
}

Full Code

The full code I have been working on is here, which defines a bunch of consonant vectors, maps them to each other, and stringifies it into somewhat human readable JSON:

import fs from "fs";

import merge from "lodash/merge";

export const CONSONANT_FEATURE_NAMES = [
  "affricate",
  "alveolar",
  "approximant",
  "aspiration",
  "bilabial",
  "click",
  "coarticulated",
  "dental",
  "dentalization",
  "ejective",
  "fricative",
  "glottal",
  "glottalization",
  "implosive",
  "labialization",
  "labiodental",
  "labiovelar",
  "lateral",
  "nasal",
  "nasalization",
  "palatal",
  "palatalization",
  "pharyngeal",
  "pharyngealization",
  "plosive",
  "postalveolar",
  "retroflex",
  "sibilant",
  "stop",
  "tap",
  "tense",
  "uvular",
  "velar",
  "velarization",
  "voiced",
] as const;

export type ConsonantFeatureName = (typeof CONSONANT_FEATURE_NAMES)[number];

export type ConsonantWithFeatures = Partial<
  Record<ConsonantFeatureName, number>
>;

export const VOWEL_FEATURE_NAMES = [
  "back", // Back vowel (tongue towards the back)
  "central", // Central vowel (tongue in the center)
  "front", // Front vowel (tongue towards the front)
  "closed", // High tongue position
  "long", // Vowel length (long or short)
  "open", // Low tongue position
  "mid", // Mid tongue position
  "nasalization", // Whether the vowel is nasalized
  "rounded", // Whether the lips are rounded
  "stress", // Whether the vowel is stressed
  "unrounded",
  "near",
] as const;

export type VowelFeatureName = (typeof VOWEL_FEATURE_NAMES)[number];

export type VowelWithFeatures = Partial<Record<VowelFeatureName, number>>;

export const VOWELS: Record<string, Float32Array> = {};

const NASALS = [0, 0.4];
const STRESSES = [0, 0.3];

NASALS.forEach((nasalization) => {
  STRESSES.forEach((stress) => {
    const keys: Array<string> = [];
    if (nasalization) {
      keys.push(`&`);
    }
    if (stress) {
      keys.push("^");
    }
    const key = keys.join("");
    merge(VOWELS, {
      [`i${key}`]: vowel({
        closed: 1.0,
        front: 1.0,
        unrounded: 1.0,
        nasalization,
        stress,
      }),
      [`e${key}`]: vowel({
        mid: 1.0,
        front: 1.0,
        unrounded: 1.0,
        nasalization,
        stress,
      }),
      [`a${key}`]: vowel({
        open: 1.0,
        front: 0.5,
        unrounded: 1.0,
        nasalization,
        stress,
      }),
      [`o${key}`]: vowel({
        closed: 1.0,
        mid: 1.0,
        back: 1.0,
        rounded: 1.0,
        nasalization,
        stress,
      }),
      [`u${key}`]: vowel({
        closed: 1.0,
        back: 1.0,
        rounded: 1.0,
        nasalization,
        stress,
      }),
      [`I${key}`]: vowel({
        near: 1.0,
        closed: 1.0,
        front: 1.0,
        unrounded: 1.0,
        nasalization,
        stress,
      }),
      [`E${key}`]: vowel({
        open: 1.0,
        mid: 1.0,
        front: 1.0,
        unrounded: 1.0,
        nasalization,
        stress,
      }),
      [`A${key}`]: vowel({
        near: 1.0,
        open: 1.0,
        front: 1.0,
        unrounded: 0.9,
        nasalization,
        stress,
      }),
      [`O${key}`]: vowel({
        near: 1.0,
        mid: 1.0,
        back: 1.0,
        unrounded: 1.0,
        nasalization,
        stress,
      }),
      [`U${key}`]: vowel({
        mid: 1.0,
        central: 1.0,
        nasalization,
        stress,
      }),
      [`i$${key}`]: vowel({
        front: 1.0,
        closed: 1.0,
        rounded: 1.0,
        nasalization,
        stress,
      }),
      [`e$${key}`]: vowel({
        closed: 1.0,
        mid: 1.0,
        front: 1.0,
        rounded: 1.0,
        nasalization,
        stress,
      }),
      [`a$${key}`]: vowel({
        open: 1.0,
        mid: 1.0,
        front: 1.0,
        rounded: 1.0,
        nasalization,
        stress,
      }),
      [`o$${key}`]: vowel({
        open: 1.0,
        mid: 1.0,
        back: 1.0,
        rounded: 1.0,
        nasalization,
        stress,
      }),
      [`u$${key}`]: vowel({
        open: 1.0,
        mid: 1.0,
        central: 1.0,
        unrounded: 1.0,
        nasalization,
        stress,
      }),
    });
  });
});

export const VOWEL_KEYS = Object.keys(VOWELS);

export const VOWEL_VECTOR_PLACEHOLDER = vowel();

// https://en.wikipedia.org/wiki/IPA_consonant_chart_with_audio
export const CONSONANTS: Record<string, Float32Array> = {
  m: consonant({ bilabial: 0.9, nasal: 1.0 }),
  N: consonant({ retroflex: 1.0, nasal: 0.8 }),
  n: consonant({ alveolar: 1.0, nasal: 0.9 }),
  q: consonant({ velar: 1.0, nasal: 0.8 }),
  "G~": consonant({ velarization: 1.0 }),
  G: consonant({ velar: 1.0, fricative: 0.8 }),
  "g?": consonant({ plosive: 1.0, velar: 1.0, implosive: 0.5 }),
  g: consonant({ plosive: 1.0, velar: 1.0 }),
  "'": consonant({ plosive: 1.0, glottal: 0.9 }),
  Q: consonant({ pharyngeal: 1.0, fricative: 0.9 }),
  "d?": consonant({ dental: 1.0, plosive: 1.0, implosive: 0.5 }),
  "d!": consonant({ dental: 1.0, plosive: 1.0, ejective: 0.5 }),
  "d*": consonant({ click: 1.0 }),
  "d.": consonant({ dental: 1.0, plosive: 1.0, stop: 0.2 }),
  D: consonant({ dental: 1.0, retroflex: 0.9, plosive: 1.0 }),
  "dQ~": consonant({
    dental: 1.0,
    plosive: 1.0,
    pharyngealization: 0.8,
  }),
  d: consonant({ dental: 1.0, plosive: 1.0 }),
  "b?": consonant({
    bilabial: 1.0,
    voiced: 1.0,
    plosive: 1.0,
    implosive: 0.5,
  }),
  "b!": consonant({
    bilabial: 1.0,
    voiced: 1.0,
    plosive: 1.0,
    ejective: 0.5,
  }),
  b: consonant({ bilabial: 1.0, voiced: 1.0, plosive: 1.0 }),
  "p!": consonant({ bilabial: 1.0, plosive: 1.0, ejective: 0.5 }),
  "p*": consonant({ bilabial: 1.0, click: 1.0 }),
  "p.": consonant({ bilabial: 1.0, plosive: 1.0, stop: 0.2 }),
  "p@": consonant({ bilabial: 1.0, plosive: 1.0, tense: 0.1 }),
  p: consonant({ bilabial: 1.0, plosive: 1.0 }),
  // ...
};

export const CONSONANT_KEYS = Object.keys(CONSONANTS);

export const CONSONANT_VECTOR_PLACEHOLDER = consonant();

function consonant(
  mappings: Partial<Record<ConsonantFeatureName, number>> = {}
) {
  const consonant = new Float32Array(CONSONANT_FEATURE_NAMES.length);
  CONSONANT_FEATURE_NAMES.forEach((name, i) => {
    const number =
      name in mappings
        ? typeof mappings[name] === "number"
          ? mappings[name]
          : mappings[name] === true
          ? 1
          : 0
        : 0;
    consonant[i] = number;
  });
  return consonant;
}

function vowel(mappings: Partial<Record<VowelFeatureName, number>> = {}) {
  const consonant = new Float32Array(VOWEL_FEATURE_NAMES.length);
  VOWEL_FEATURE_NAMES.forEach((name, i) => {
    const number =
      name in mappings
        ? typeof mappings[name] === "number"
          ? mappings[name]
          : mappings[name] === true
          ? 1
          : 0
        : 0;
    consonant[i] = number;
  });
  return consonant;
}

const startingConsonants = CONSONANT_KEYS.map((key) => [{ key }]).concat(
  `bl
br
cl
cr
dr
fl
fr
gl
gr
kl
kr
pl
pr
sl
sm
sn
sp
st
tr
tw
sk
sw
sr
sc
th
sh
ch
ph
thw
sch
spl
spr
str
skr
slj
trj
blj
blw
drw
brw
kw
grw
kn
gn
zl
zn
vl
vr
ps
pt
pn
skh
sth
sf
ks
zh
zv
spn
shl
skl
smn
shr
chv
thn
klh
ql
phn
zr
brl
grk
ndr
ndl
sdr
skn
slz
zj
zd
rz
tsr
tn
prn
skj
svk
dj
trl
khr
chr
tsw
thr
ghn`
    .trim()
    .split(/\n+/)
    .map((text) => text.split("").map((key) => ({ key })))
);

export type Substitution = {
  key: string;
  vector: Float32Array;
};

export type SubstitutionList = {
  list: Array<Substitution>;
  vector: Float32Array;
};

export type Cluster = {
  key: string;
};

export type Substitutions = Record<string, SubstitutionList>;

const startingConsonantSubstitutions = startingConsonants.reduce<Substitutions>(
  (map, cluster) => {
    const list = startingConsonants.map(consonant);
    const { key, vector } = consonant(cluster);

    map[key] = { list, vector };

    return map;
  },
  {}
);

const substitutions = {
  ...startingConsonantSubstitutions,
};

fs.writeFileSync(`make/rhymes/substitutions.json`, stringify(substitutions));

function stringify(substitutions: Substitutions) {
  const text = [];

  text.push(`{`);

  let n = Object.keys(substitutions).length;
  let i = 0;
  for (const key in substitutions) {
    const sub = substitutions[key]!;
    text.push(`  "${key}": {`);
    sub.list.forEach((item, i) => {
      const tail = i === sub.list.length - 1 ? "" : ",";
      const sum = weightedJaccard(sub.vector, item.vector);
      text.push(`    "${item.key}": ${sum}${tail}`);
    });
    const tail = i === n - 1 ? "" : ",";
    text.push(`  }${tail}`);
    i++;
  }

  text.push(`}`);
  return text.join("\n");
}

// function consonant(symbols: Array<Cluster>) {
//   const vector = new Float32Array(
//     4 * CONSONANT_VECTOR_PLACEHOLDER.length,
//   )
//   symbols.forEach((symbol, i) => {
//     const consonant = CONSONANTS[symbol.key]!
//     let j = 0
//     while (j < consonant.length) {
//       vector[i * 4 + j] = consonant[j]!
//       j++
//     }
//   })
//   return { key: symbols.map(({ key }) => key).join(''), vector }
// }
function consonant(symbols: Array<Cluster>) {
  const vector = combineConsonantClusterVectors(
    symbols.map((symbol) => symbol.key)
  );

  return { key: symbols.map(({ key }) => key).join(""), vector };
}

export function combineConsonantClusterVectors(
  clusters: Array<string>
): Float32Array {
  const vector = new Float32Array(CONSONANT_VECTOR_PLACEHOLDER.length);

  clusters.forEach((cluster) => {
    const consonantVector =
      CONSONANTS[cluster] ||
      new Float32Array(CONSONANT_VECTOR_PLACEHOLDER.length);

    for (let i = 0; i < vector.length; i++) {
      vector[i]! += consonantVector[i]!;
    }
  });

  return normalizeVector(vector);
}

export function normalizeVector(vector: Float32Array): Float32Array {
  const max = Math.max(...vector);
  if (max === 0) {
    return vector;
  } // Avoid division by zero if the vector is all zeros.
  return vector.map((value) => value / max);
}

export function weightedJaccard(a: Float32Array, b: Float32Array): number {
  let intersectionSum = 0;
  let unionSum = 0;

  for (let i = 0; i < a.length; i++) {
    const valueA = a[i]!;
    const valueB = b[i]!;

    // Weighted Intersection: take the minimum of both values
    intersectionSum += Math.min(valueA, valueB);

    // Weighted Union: take the maximum of both values
    unionSum += Math.max(valueA, valueB);
  }

  // Return the Jaccard similarity, or 0 if the union is 0 to avoid division by zero
  return unionSum === 0 ? 0 : intersectionSum / unionSum;
}

Toward the bottom of that code block is this:

export type Substitution = {
  key: string
  vector: Float32Array
}

export type SubstitutionList = {
  list: Array<Substitution>
  vector: Float32Array
}

export type Cluster = {
  key: string
}

export type Substitutions = Record<string, SubstitutionList>

const startingConsonantSubstitutions =
  startingConsonants.reduce<Substitutions>((map, cluster) => {
    const list = startingConsonants.map(consonant)
    const { key, vector } = consonant(cluster)

    map[key] = { list, vector }

    return map
  }, {})

const substitutions = {
  ...startingConsonantSubstitutions,
}

That is where the final result is generated of the substitution mapping JSON.

Results

Currently, (writing that substitutions out to a JSON file), it currently results in mappings like this (full example here):

{
  "m": {
    "m": 1,
    "N": 0.27586207534413565,
    "n": 0.3103448219163239,
    "q": 0.27586207534413565,
    "G~": 0,
    "G": 0,
    "g?": 0,
    "g": 0,
    "'": 0,
    "Q": 0,
    "d?": 0,
    "d!": 0,
    "d*": 0,
    "d.": 0,
    "D": 0,
    "dQ~": 0,
    "d": 0,
    "b?": 0.19999999470180935,
    "b!": 0.19999999470180935,
    "b": 0.22499999403953552,
    "p!": 0.25714285033089773,
    "p*": 0.29999999205271405,
    "p.": 0.28124999228748493,
    "p@": 0.2903225728146864,
    "p": 0.29999999205271405,
    "T!": 0,
    "T": 0,
    "t!": 0,
    "t*": 0,
    "tQ~": 0,
    "t@": 0,
    "t.": 0,
    "t": 0,
    "k!": 0,
    "k.": 0,
    "k*": 0,
    "K!": 0,
    "K": 0,
    "k": 0,
    "H!": 0,
    "H": 0,
    "h~": 0,
    "h!": 0,
    "h": 0,
    "J": 0,
    "j!": 0,
    "j": 0,
    "S!": 0,
    "s!": 0,
    "S": 0,
    "sQ~": 0,
    "s@": 0,
    "s": 0,
    "F": 0.29999999205271405,
    "f!": 0,
    "f": 0,
    "V": 0.22499999403953552,
    "v": 0,
    "z!": 0,
    "zQ~": 0,
    "z": 0,
    "Z!": 0,
    "Z": 0,
    "CQ~": 0,
    "C": 0,
    "cQ~": 0,
    "c": 0,
    "L": 0,
    "l*": 0,
    "lQ~": 0,
    "l": 0,
    "R": 0,
    "rQ~": 0,
    "r": 0,
    "x!": 0,
    "X!": 0,
    "X": 0,
    "x@": 0,
    "x": 0,
    "W": 0,
    "w!": 0,
    "w~": 0,
    "w": 0,
    "y~": 0,
    "y": 0,
    "bl": 0.14999999602635702,
    "br": 0.14999999602635702,
    "cl": 0,
    "cr": 0,
    "dr": 0,
    "fl": 0,
    "fr": 0,
    "gl": 0,
    "gr": 0,
    "kl": 0,
    "kr": 0,
    "pl": 0.1799999952316284,
    "pr": 0.1799999952316284,
    "sl": 0,
    "sm": 0.3877550990618253,
    "sn": 0.11538461303334734,
    "sp": 0.14999999602635702,
    "st": 0,
    "tr": 0,
    "tw": 0,
    "sk": 0,
    "sw": 0,
    "sr": 0,
    "sc": 0,
    "th": 0,
    "sh": 0,
    "ch": 0,
    "ph": 0.1799999952316284,
    "thw": 0,
    "sch": 0,
    "spl": 0.11249999701976776,
    "spr": 0.10204081682302911,
    "str": 0,
    "skr": 0,
    "slj": 0,
    "trj": 0,
    "blj": 0.09999999735090467,
    "blw": 0.0925925930014036,
    "drw": 0,
    "brw": 0.09999999735090467,
    "kw": 0,
    "grw": 0,
    "kn": 0.1836734654157671,
    "gn": 0.1836734654157671,
    "zl": 0,
    "zn": 0.1836734654157671,
    "vl": 0,
    "vr": 0,
    "ps": 0.14999999602635702,
    "pt": 0.147058824560634,
    "pn": 0.44999998807907104,
    "skh": 0,
    "sth": 0,
    "sf": 0,
    "ks": 0,
    "zh": 0,
    "zv": 0,
    "spn": 0.21590908936971476,
    "shl": 0,
    "skl": 0,
    "smn": 0.3589743550555789,
    "shr": 0,
    "chv": 0,
    "thn": 0.10227272511760065,
    "klh": 0,
    "ql": 0.16326530934968925,
    "phn": 0.29999999205271405,
    "zr": 0,
    "brl": 0.11249999701976776,
    "grk": 0,
    "ndr": 0.10227272511760065,
    "ndl": 0.13043477960405067,
    "sdr": 0,
    "skn": 0.09183673270788355,
    "slz": 0,
    "zj": 0,
    "zd": 0,
    "rz": 0,
    "tsr": 0,
    "tn": 0.132352938598415,
    "prn": 0.24358974202223155,
    "skj": 0,
    "svk": 0,
    "dj": 0,
    "trl": 0,
    "khr": 0,
    "chr": 0,
    "tsw": 0,
    "thr": 0,
    "ghn": 0.13043477960405067
  },
  ...
}

Analysis

The only one that seems correct is m:m (m mapped to itself, which is 1). That makes sense, it's an exact match. However, we should find m:n, and m:N (at least), to be like 0.9 or 0.95, since they are both nasal consonants which sound extremely similar.

It looks like all the consonant clusters matching *n have at least some value, but not sure if it makes sense what it ends up being. I don't have in mind exactly what these final weightedJaccard sums should be even (I am brand new to all these NLP/ML algorithms, trying to wire them together carefully).

All the rest of the consonant clusters are 0, which probably makes sense, since if they don't have m or n, then they are probably not a good match (though not 100% sure yet if that is desired, I'd have to tinker with phonetic relationships more manually first).

Then p and b are "labial" consonants (like m and n), so they have some value, but I would expect it to be slightly higher than 0.1-0.3.

Finally, F and V, which are both technically "bilabial" consonants, they are "fricatives" however (like blowing wind through your lips), whereas b and p are explosions of sound ("plosives"), not streams of sound.

Here are all the key characters that relate to m (at least as far as this data turned out):

I would expect it to be more like this number wise:

That is how I personally (subjectively) would give them values. The main thing there is F and V (while technically being "bilabial"), have no audible relationship to the sound m (like sounds "f" and "v" basically).

Question

Any ideas what I'm doing wrong? It seems that my feature vector mappings (done onto the CONSONANTS map object) are not going to result in the desired relationships/similarities between sounds. There are like 1-4 props per vector there, and it seems like "why wouldn't any vector with 4 defined props with all values 1.0 result in the same vector embedding (so basically, it wouldn't respect the fact that these properties represent totally different things). So I'm confused as to how this will ever get close to resulting in a "similarity mapping".

How can I even begin to make features and assign floating point number values to each feature, so that I can get these sounds which I subjectively think are similar, to in fact be correlated in the numbers?

Summary

The basic flow of the code is:

Looking to better understand where I'm going wrong in calculating the similarity between consonant clusters using the weight jaccard approach.

Note: Got the idea for using the jaccard sum from this paper: arxiv.org/abs/2109.14796. Not sure if I'm doing it right, or even if that's the best approach to my overall problem. But for this question, we can assume we want to use the weight jaccard sum idea, unless you feel strongly otherwise.

Update

Note: Plugging this into Claude AI, it gave what seems like a helpful suggestion, to completely change my base consonant vector system, sort of like this:

// Define a more detailed feature space
const FEATURE_SPACE = {
  placeOfArticulation: {
    features: ['bilabial', 'labiodental', 'dental', 'alveolar', 'postalveolar', 'retroflex', 'palatal', 'velar', 'uvular', 'pharyngeal', 'glottal'],
    weight: 0.3,
    // Define relationships between places of articulation
    similarity: (a: string, b: string) => {
      const order = ['bilabial', 'labiodental', 'dental', 'alveolar', 'postalveolar', 'retroflex', 'palatal', 'velar', 'uvular', 'pharyngeal', 'glottal'];
      const distance = Math.abs(order.indexOf(a) - order.indexOf(b));
      return 1 - (distance / (order.length - 1));
    }
  },
  manner: {
    features: ['plosive', 'nasal', 'trill', 'tap', 'fricative', 'lateral', 'approximant', 'affricate'],
    weight: 0.3,
    // Define relationships between manners of articulation
    similarity: (a: string, b: string) => {
      const groups = [
        ['plosive', 'affricate'],
        ['fricative', 'approximant'],
        ['nasal', 'lateral'],
        ['trill', 'tap']
      ];
      if (a === b) return 1;
      if (groups.some(group => group.includes(a) && group.includes(b))) return 0.5;
      return 0;
    }
  },
  voicing: {
    features: ['voiced', 'voiceless'],
    weight: 0.2,
    similarity: (a: string, b: string) => a === b ? 1 : 0
  },
  airflow: {
    features: ['oral', 'nasal'],
    weight: 0.1,
    similarity: (a: string, b: string) => a === b ? 1 : 0
  },
  release: {
    features: ['aspirated', 'unaspirated'],
    weight: 0.1,
    similarity: (a: string, b: string) => a === b ? 1 : 0
  }
};

type FeatureSpace = typeof FEATURE_SPACE;

function createDetailedFeatureVector(consonant: string): number[] {
  const features = CONSONANTS[consonant];
  const vector: number[] = [];

  Object.entries(FEATURE_SPACE).forEach(([category, { features, weight }]) => {
    features.forEach(feature => {
      const value = features[feature] || 0;
      vector.push(value * weight);
    });
  });

  return vector;
}

function calculateFeatureSimilarity(feature1: string, feature2: string, category: keyof FeatureSpace): number {
  const { similarity } = FEATURE_SPACE[category];
  return similarity(feature1, feature2);
}

function consonantSimilarity(c1: string, c2: string): number {
  let totalSimilarity = 0;
  let totalWeight = 0;

  Object.entries(FEATURE_SPACE).forEach(([category, { features, weight }]) => {
    const f1 = features.find(f => CONSONANTS[c1][f]) || '';
    const f2 = features.find(f => CONSONANTS[c2][f]) || '';
    
    if (f1 && f2) {
      const similarity = calculateFeatureSimilarity(f1, f2, category as keyof FeatureSpace);
      totalSimilarity += similarity * weight;
      totalWeight += weight;
    }
  });

  return totalSimilarity / totalWeight;
}

// Example usage
console.log(consonantSimilarity('m', 'n')); // Should be high (both nasals)
console.log(consonantSimilarity('p', 'b')); // Should be high (differ only in voicing)
console.log(consonantSimilarity('p', 'k')); // Should be moderate (both voiceless plosives, different place)
console.log(consonantSimilarity('p', 's')); // Should be lower (different manner and place)

Not sure why my system won't work, and why this might be better. Slightly another way of looking at the problem perhaps, maybe better too.

Should I be doing something more like that to organize my features? Maybe that's why I'm getting unexpected results...

Upvotes: 2

Views: 41

Answers (0)

Related Questions