Decreasing Cord-Value Groups


1. Decreasing Cord-Value Groups

AS020 has an intriguing property. Its two cord groups are arranged in descending sorted order. That is: Pi > Pi+1 for all the cords in a group. You can see the obvious mnemonic help here. By organizing cords by largest to smallest, it makes it easier for the reader to recite the khipu. There is an occasional hiccup, where one cord is slightly less than it’s neighbor, but the concept still holds, in general.

There are two ways to evaluate this relationship. Method 1 is to assume that the values are ordered for a “majority” (i.e. an allowable percentage) of the items in the list. Method 2 is to assume the decreasing values are linear, and do a goodness of fit line approach. The first method is more forgiving (generating greater recall) but is less accurate (generating a corresponding decrease in precision) matching close to 60% of the database. The second approach only exists in about 20% of the khipus.

I have tried both approaches and at the end of the day, I prefer the more precise least-squares line fitting approach:
  1. Do a least-squares line-fit to the data.
  2. If the line’s slope is negative, it’s decreasing.
  3. If the residuals of the fit are within a given tolerance, it’s decreasing linearly.

2. Search Criteria:

Using this approach of “line-fit” with residuals, we can see which khipus have interesting “decreasing groups.”

Code
def best_fit_line(x_values,y_values):
    """ Find slope, intercept of best fit line through x,y """
    # https://enlight.nyc/projects/linear-regression
    x_dot_y = [x*y for x,y in list(zip(x_values, y_values))]
    normalizer = (mean(x_values)**2 - mean([x**2 for x in x_values]))
    m = ((mean(x_values) * mean(y_values)) - mean(x_dot_y)) / normalizer if normalizer != 0.0 else 0.0
    b = mean(y_values) - m * mean(x_values)
    return m, b

def r_squared_value(original_y,predicted_y):
    """ Measures goodness of fit by comparing original y values to predicted values. 
        Returns 0 to 1 (i.e. 100% = 1.0) """
    # https://enlight.nyc/projects/linear-regression
    def squared_error(original_y, predicted_y):
        # helper function to return the sum of the distances between the two y values squared
        return sum([(prediction - original)**2 for prediction, original in list(zip(predicted_y, original_y))])

    squared_error_regr = squared_error(original_y, predicted_y) # squared error of regression line
    y_mean_line = [mean(original_y) for y in original_y] # horizontal line (mean of y values)
    squared_error_y_mean = squared_error(original_y, y_mean_line) # squared error of the y mean line
    if squared_error_y_mean > 0.0:
        return 1 - (squared_error_regr/squared_error_y_mean)
    else:
        return 0.0

def slope_intercept_linear_regression(list_of_points):
    """ Calculates m/slope, b/intercept, and r^2/residual of a list of points 
        list_of_points = [(x0,y0), (x1,y1) ... """ 
    # list_of_points.sort(key = lambda x: x[0]) # Sort points by x_coord... (usually a 1,2,3 type index)
    x_vals = [x for (x,y) in list_of_points]
    y_vals = [y for (x,y) in list_of_points]
    # Calculate slope, intercept
    m,b = best_fit_line(x_vals, y_vals)
    # Calculate residuals r^2
    predicted_line = [(m*x)+b for x in x_vals]
    r_squared = r_squared_value(y_vals, predicted_line)
    return r_squared, m, b

# Here is the test to see if the list of cord-values is roughly increasing, AND IN A LINE
# Here is the test to see if the list of cord-values is roughly decreasing, AND IN A LINE
def roughly_linear_decreasing(list_of_points, tolerance = 0.4):
    """ If r^2/residuals > 1.0 - tolerance and slope is negative (but not near angles of 0 or 90°!) return True.
        list_of_points = [(x0,y0), (x1,y1) ... """ 
    r_squared, m, b = slope_intercept_linear_regression(list_of_points)
    is_linear = (r_squared > (1.0 - tolerance))

    # convert slope to angle theta
    theta=math.atan(m)
    theta=theta/math.pi*180
    reasonable_slope = (theta > -87.0) and (theta < -5.0)

    return is_linear and reasonable_slope

# Here is the simple test to see if the list of cord-values is roughly decreasing, ignoring if it's a line
def roughly_decreasing_list(list_of_values, tolerance = 0.7):
    is_greater_left = [list_of_values[i] >= list_of_values[i+1] for i in range(len(list_of_values)-1)]
    is_decreasing = sum(is_greater_left) > tolerance*(len(list_of_values)-1) # 70% of points must qualify
    return is_decreasing
Code
# Khipu Imports
import qollqa_chuspa as qc
# Load khipus
khipu_dict, all_khipus = qc.fetch_khipus()

3. Summary Results:

Measure Result
Number of Khipus That Match 90 (14%)
Number of Significant Khipus 25 (4%)
Five most Significant Khipu UR208, UR263, JC001, UR113, AS212
XRay Image Quilt Click here
Database View Click here

4. Summary Charts:

Code
# Read in the Fieldmark and its associated dataframe and match dictionary
from fieldmark_ascher_decreasing_group import FieldmarkAscherDecreasingGroup
import pandas as pd
import plotly
import plotly.graph_objs as go
import plotly.express as px

aFieldmark = FieldmarkAscherDecreasingGroup()
fieldmark_dataframe = aFieldmark.dataframes[0].dataframe
raw_match_dict = aFieldmark.raw_match_dict()
Code
# Initialize plotly
plotly.offline.init_notebook_mode(connected = False);

# Plot Matching khipu
matching_khipus = aFieldmark.matching_khipus() 
matching_values = [raw_match_dict[aKhipuName] for aKhipuName in matching_khipus]
matching_df =  pd.DataFrame(list(zip(matching_khipus, matching_values)), columns =['KhipuName', 'Value'])
fig = px.bar(matching_df, x='KhipuName', y='Value', labels={"KhipuName": "Khipu Name", "Value": "Decreasing Cord-Value Groups", }, 
            title=f"Matching Khipu ({len(matching_khipus)}) for Decreasing Cord-Value Groups",  width=944, height=450).update_layout(showlegend=True).show()
Code
# Plot Significant khipu
significant_khipus = aFieldmark.significant_khipus()
significant_values = [raw_match_dict[aKhipuName] for aKhipuName in significant_khipus]
significant_df =  pd.DataFrame(list(zip(significant_khipus, significant_values)), columns =['KhipuName', 'Value'])
fig = px.bar(significant_df, x='KhipuName', y='Value', labels={"KhipuName": "Khipu Name", "Value": "Number of Decreasing Cord-Value Groups", },
             title=f"Significant Khipu ({len(significant_khipus)}) for Decreasing Cord-Value Groups", width=944, height=450).update_layout(showlegend=True).show()

5. Significance Criteria:

This is one of those types of fieldmarks that simply existing makes it significant. We could get picky and look at things like line-slope, (I did trim slopes of close to 90 degrees and 0 degrees), etc. but the simple fact is this Ascher relationship only occurs in 18 (3.5%) of the KFG khipus in 2 or more groups.

6. Exploratory Data Analysis

6.1 Review by Group Slope/Fit/Mean/Intercept…

As a basic first pass let’s review the numbers about each decreasing cord group. How much does it slope, where is it’s intercept (or mean), how well does the data fit a line?

Code
import warnings
import math
warnings.filterwarnings("ignore")

dgroup_df = pd.DataFrame(columns = ['kfg_name', 'num_groups', 'num_decreasing_groups', 
                                      'm', 'b', 'residual',
                                      'num_cords',
                                      'mean_cord_value', 
                                      'proportion_decreasing_groups']) 
decreasing_khipus =  [aKhipu for aKhipu in all_khipus if aKhipu.kfg_name() in significant_khipus]
for i, aKhipu in enumerate(decreasing_khipus):
    for aGroup in aKhipu.cord_groups():
        if aGroup.is_roughly_linearly_decreasing():
            (m,b,residual) = aGroup.linear_slope()
            #Convert slope to angle
            theta=math.atan(m)
            theta=theta/math.pi*180
            record = {'kfg_name': aKhipu.kfg_name(), 
                      'num_groups': aKhipu.num_cord_groups(),
                      'num_decreasing_groups': aKhipu.num_linearly_decreasing_groups(),
                      'm':theta, 
                      'b':b, 
                      'residual':residual,
                      'num_cords': aGroup.num_pendant_cords(),
                      'mean_cord_value': aGroup.mean_cord_value(),
                      'proportion_decreasing_groups': float(aKhipu.num_decreasing_groups())/float(aKhipu.num_cord_groups()),
                      }
            dgroup_df = pd.concat([dgroup_df, pd.DataFrame([record])], ignore_index=True)
#dgroup_df
Code
fig = px.scatter(dgroup_df, x="m", y="mean_cord_value", color=list(dgroup_df.residual.values),
                 size=list(dgroup_df.num_cords.values),
                 labels={"m": "Group Slope (0 to -90°)", "mean_cord_value": "Mean Cord Value"},
                 hover_data=['kfg_name'], 
                 title="<b>Decreasing Groups by Slope & Mean Cord Value</b> Size=#Cords, Color=Residual",
                 width=944, height=944).update_layout(showlegend=True).show()

Oooh. I like this graphic. With the exception of the items on the far left (essentially vertical lines who only have 3 cord values!), this type of ordering appears across a broad spectrum of values, and across a broad set of number of cords (the most being 17 cords on UR113, the least being 4 when the lines stop being vertical.)

6.2 Review by Banded vs. Seriated

Code
def fetch_group(kfg_name, group_index): 
    return khipu_dict[kfg_name][int(group_index)]

groups_df = aFieldmark.dataframes[1].dataframe
group_tuples = list(zip(list(groups_df.kfg_name.values), list(groups_df.group_index.values)))
groups = [fetch_group(kfg_name, group_pos) for (kfg_name, group_pos) in group_tuples]
banded_groups = [aGroup for aGroup in groups if aGroup.is_banded_group()]
seriated_groups = [aGroup for aGroup in groups if not aGroup.is_banded_group()]
print(f"Out of {len(groups)} groups, there are {len(banded_groups)} banded groups and {len(seriated_groups)} seriated_groups")
Out of 247 groups, there are 42 banded groups and 205 seriated_groups

On initial visual review of the X-Ray Image Quilt, it becomes apparent that the majority of ascher decreasing groups (4 out of 5) are on seriated groups. However they do occur on banded groups - for example UR231 makes ample use of this relationship for a series of banded groups.

6.3 Review by Ascher Color

Since we know that these groups are in decreasing order, it might make sense to look at the color operators for each of these groups. The decreasing order in value may correlate to color choice.

Code
color_strings = [[aCord.main_color() for aCord in aGroup[:]] for aGroup in seriated_groups]
neighbors = []
for color_string in color_strings:
    for i in range(len(color_string)-1):
        if color_string[i] != color_string[i+1]:
            neighbors.append((color_string[i], color_string[i+1]))
from collections import Counter
neighbor_counter = Counter(neighbors)
neighbor_counter.most_common(30)
[(('W', 'AB'), 19),
 (('W', 'MB'), 18),
 (('W', 'YB'), 14),
 (('MB', 'W'), 12),
 (('AB', 'GG'), 11),
 (('GG', 'W:KB'), 11),
 (('YB', 'BS'), 11),
 (('BS', 'GG'), 11),
 (('YB', 'W'), 10),
 (('GG', 'MB:LK'), 8),
 (('MB', 'W:MB'), 7),
 (('MB', 'AB'), 7),
 (('YB', 'W:FB'), 7),
 (('W:FB', 'YB'), 7),
 (('YB', '0G'), 7),
 (('W', 'LB'), 6),
 (('W:MB', 'W'), 6),
 (('NB', 'YB'), 6),
 (('B', 'BB'), 6),
 (('YB', 'LB'), 5),
 (('AB', 'MB'), 5),
 (('B', 'W'), 4),
 (('W', 'B'), 4),
 (('LB', 'YB:LB'), 4),
 (('YB', 'MB'), 4),
 (('W', 'W:B'), 4),
 (('KB', 'NB'), 4),
 (('AB', 'W'), 4),
 (('W', 'RB'), 4),
 (('AB:MB', 'MB'), 4)]

Oh. So sad. There is no apparent order. For example, where White is greater than brown, brown ends up being greater than white. (‘AB:W’, ‘KB:W’) is followed by (‘KB:MB’, ‘AB’), etc. For every significant relationship there usually is an approximately equally significant opposite relationship.

6.4 Review of database by Benford Match

Let’s review the database, by “benford-match” criteria. Recall that Benford-match was a metric for measuring how “accounting” in nature a khipu is.

Additionally, a look at the created image_quilt shows an intriguing property. In many of the khipus, these “sorted-descending” groups appear to up on the “right half” of the primary cord. In fact many of the Ascher khipu seem to show a left section consisting of some sort of preamble, and a right sections consisting of similar repeated groups with some sort of interesting Ascher property such as sorted-descending cord values… Is that a statistically valid observation? Let’s measure that as well, using the position index of a decreasing cord group as a “moment-arm” to see where the balance lies.

Code
import warnings
warnings.filterwarnings("ignore")
from statistics import mean

def khipu_moment(aKhipu):
    """ If the moment-weight is '1', then the moment-arm is simply the average of the position indices """
    mid_point = aKhipu.num_cord_groups()/2.0
    decreasing_groups = [aGroup for aGroup in aKhipu.cord_groups() if aGroup.is_roughly_linearly_decreasing()]
    moment_pt = mean([aGroup.position()+1 for aGroup in decreasing_groups]) if len(decreasing_groups) > 0 else mid_point
    return moment_pt - mid_point

decreasing_khipus = [aKhipu for aKhipu in all_khipus if aKhipu.kfg_name() in matching_khipus]
dCC_df = pd.DataFrame(columns = ['kfg_name', 'num_groups', 'num_decreasing_groups', 
                                 'moment', 
                                 'benford_match', 
                                 'mean_decreasing_cord_value', 
                                 'proportion_decreasing_groups']) 
for i, aKhipu in enumerate(decreasing_khipus):
    decreasing_cord_groups = [aGroup for aGroup in aKhipu.cord_groups() if aGroup.is_roughly_linearly_decreasing()]
    if decreasing_cord_groups:
        mean_group_value = mean([aGroup.mean_cord_value() for aGroup in decreasing_cord_groups])
        record = {'kfg_name': aKhipu.kfg_name(), 
                  'num_groups': aKhipu.num_cord_groups(),
                  'num_decreasing_groups': aKhipu.num_linearly_decreasing_groups(),
                  'moment': khipu_moment(aKhipu), 
                  'benford_match': aKhipu.benford_match(),
                  'mean_decreasing_cord_value': mean_group_value,
                  'proportion_decreasing_groups': float(aKhipu.num_decreasing_groups())/float(aKhipu.num_cord_groups()),
                  }
        dCC_df = pd.concat([dCC_df, pd.DataFrame([record])], ignore_index=True)
Code
fig = px.scatter(dCC_df, x="benford_match", y="num_decreasing_groups", 
                 size=list(dCC_df.mean_decreasing_cord_value.values),
                 color=list(dCC_df.proportion_decreasing_groups.values),
                 log_y=True,
                 labels={"benford_match": "Benford Match", "num_decreasing_groups": "Number of Decreasing Cord Value Groups in Khipu"},
                 hover_data=['kfg_name'], 
                 title="<b>Khipus with Decreasing Groups by Benford Match</b> - Marker Size is Mean of Decreasing Groups",
                 width=944, height=944)\
        .update_layout(showlegend=True).show()

6.5 Review by Location

Where do the groups appear on a khipu? We can graph them with a simple text string. Such a simple display shows that decreasing cord value groups frequently group together:

Code
def group_string_val(aGroup): return "!"  if aGroup.is_roughly_decreasing() else "."

decreasing_khipus.sort(key=lambda aKhipu: aKhipu.num_cord_groups()*100 + aKhipu.num_linearly_decreasing_groups(), reverse=True)
for aKhipu in decreasing_khipus:
    string_rep = "".join([group_string_val(aGroup) for aGroup in aKhipu.cord_groups()])
    moment_rep = "%.2f" % khipu_moment(aKhipu)
    print (f"{moment_rep}:  {string_rep}")
-4.70:  ..........................................................................................!.................
34.50:  .......!...!!!!!.!!!!!.!..!!..!!!...!...!....!!!.!...!....!!.!!!!!...!.!.!!!!!...!..!!!!...!.....!!!.......
9.50:  .......!...................................................!..................................
-0.75:  .!........!.!...........!.............!!..!.........!..............!............
28.50:  .....!........!................................................
-11.50:  ...................!...........!.......................!.....
-15.33:  ...!.!.....!........!....!..............!...................
14.50:  ....!!.!!!...!!......!!.!!!...!!..!!......!!!
17.00:  .........................!..................
-1.50:  !.!.!..!!..........!...................
-0.50:  ......................................
-4.00:  !!.....!...........!.....!...!...!...
5.83:  ....!..!..!...........!!...........
6.50:  .!..!!!..!.!!!!!!!!..!!!.!!!!!!.
11.00:  ............!.......!...!!....!.
8.59:  .!!..............!!!.!!!!.!!..!
-2.50:  !.!!.....!!!.!!!!!...!.........
-10.50:  !...!!..!!!!............!...!.!
-12.00:  ..............................
-12.00:  ..!...!.......................
2.50:  ...........!!..........!....!
-7.50:  !.....!!...!!.......!.!......
-10.00:  .!..........................
10.50:  .......................!.!.
3.00:  .....!.........!!......!.!
4.33:  .!.......................
0.30:  ......!..............!...
-7.50:  ....!.!.....!....!.......
-1.00:  .....!....!!.........!..
3.00:  .....!.................
-7.50:  !.!!.!..!!!!!!!!!!!!..
2.00:  ..........!...........
6.50:  .....!..!!..!..!!!!!.
8.50:  !.!...!!..........!..
8.00:  .................!..
3.00:  ....................
-4.00:  !.!!...!!!......!.
2.00:  .........!!.......
6.00:  ...!!!!.!...!.!..!
2.83:  ....!.!..!.....!!
-0.75:  .......!!!.......
-3.50:  ................
-1.00:  .!..!...........
4.00:  ...........!.!.!
6.00:  ....!....!!..!!.
1.00:  !.........!!....
8.00:  ................
1.17:  ..!!..!.....!!
1.25:  ..............
1.00:  ....!.!....!..
1.07:  ..!!...!!...!
5.50:  ........!..!.
0.00:  ..!!...!....
2.00:  ....!!.!..!.
5.00:  ..........!.
-4.00:  !..!!!!!.!!.
-1.50:  ...........
0.50:  ..!!!...!..
1.50:  ...!.!!...!
-2.50:  ......!.!..
-0.50:  .!!.!......
2.00:  ....!.!!!.
-2.00:  ..!.......
5.00:  ..!.!.!.!!
-3.00:  .!........
3.00:  ....!..!..
-3.00:  ...!.!....
4.00:  ..........
4.00:  ..........
5.00:  ......!!!.
1.17:  !........
-1.00:  ..!......
0.00:  .........
1.50:  ......!..
1.50:  .........
0.50:  .!!!..!..
-3.50:  !.....!..
-1.50:  ..!.!....
0.40:  !!!...!!
-0.67:  ......!.
-3.00:  !.!!....
3.00:  ........
3.00:  ........
-2.00:  .!......
-0.50:  .!!.!!!
-2.50:  .......
-2.50:  !!!....
-1.00:  !.!...
0.50:  ..!!..
3.00:  .....!
-1.00:  ......
0.00:  ..!.!.
-1.00:  ......
1.00:  ....!.
-2.00:  ......
-0.50:  ..!!.
-1.50:  !.!.!
-0.50:  .!!!.
1.50:  .!...
-1.50:  ..!..
0.50:  .!!..
1.50:  ..!.
-1.00:  ....
1.00:  ....
1.00:  ..!.
-1.00:  !..!
-1.00:  !!!.
0.00:  ....
0.00:  .!!.
0.50:  .!.
1.50:  ..!
-0.50:  !!.
0.50:  ...
0.50:  .!.
0.50:  !!
0.50:  ..
0.50:  !!
0.00:  ..
0.00:  !.
0.00:  !.
1.00:  ..
0.50:  !
0.50:  !
0.50:  .

6.6 Review by Moment Arm

We can also review the khipus by how “balanced” are they. Do the decreasing cord groups appear on the right, like our gestalt looks indicate?

Code
mean_moment_rep = mean([khipu_moment(aKhipu) for aKhipu in decreasing_khipus])
mean_moment_rep_text = f'{mean_moment_rep:2.1f}'
print(f"Average moment arm is: {mean_moment_rep}")    


fig = px.scatter(dCC_df, x="moment", y="num_groups", 
                 size=list(dCC_df.num_decreasing_groups.values),
                 color=list(dCC_df.proportion_decreasing_groups.values),
                 labels={"moment": "Khipu Moment Arm", "num_groups": "Number of Overall Groups in Khipu"},
                 hover_data=['kfg_name'], 
                 title="<b>Khipus with Decreasing Groups</b> - Size=#Decreasing Groups, Color=Proportion",
                 width=944, height=750)\
        .add_shape(type="line", x0=mean_moment_rep, y0=0, x1=mean_moment_rep, y1=90)\
        .add_annotation(x=8, y=70,
            text=f"Mean moment arm is ~{mean_moment_rep_text} (Vertical Line)",
            showarrow=False)\
        .update_layout(showlegend=True).show()
Average moment arm is: 0.9939167015779918

Hmmn. The data does appear to support (a little) the hypothesis that on long khipus, the decreasing cord groups lie well to the right.

7. Conclusions & Further Explorations

Overall conclusions:

  • Decreasing cord value groups occur in roughly 3% of the khipus 2 or more times (18 of 592 khipus).
  • Decreasing cord value groups occur in roughly 1/7th (14%) of the khipus (67 of 592 khipus).
  • Of those groups that are decreasing cord groups, 4 seriated groups for every banded group (4:1) (compared to the overall KFG prior distribution of 4 seriated groups for every 3 banded groups (1.33:1).
  • Other than the fact that white predominates as a first cord color, there does not appear to be any apparent ordering significance on seriated groups with respect to Ascher cord colors.
  • Of the khipus that they are on, they occur roughly on 1/3 of the groups.
  • They occur slightly more frequently on the right half of the khipu (analyzed by a moment arm analogy).
  • They frequently occur in groups of 2 or 3 groups.
  • Their mean benford match signature (.707) indicates that they are largely of an “accounting” nature.
  • A few khipu bear closer inspection - these include UR208, UR113, UR231, UR068, and AS212

Further Explorations:

We have enough groups that are decreasing that we should cross-correlate seriated group counts/orders with the textual testimony of khipu orators such as those mentioned in Textos Andinos by Jukka Kiviharju and Martti Pärssinen