Structure and group labels¶
Principle¶
Many statistics, including \(F_{ST}\), require that a structure is defined.
In EggLib, the structure is controlled by instances of a specific class
(Structure
) that define groups and identify samples that
belong to them. These structure objects define which samples belong to
the outgroup and, how the other samples are organized in individuals
and/or populations and/or clusters of populations. In this way, a
three-level hierarchical structure can be defined. It is not required
to provide information for all the three levels.
The rationale of this system is to allow processing the same data set
assuming different structurations (in order to either compare the
effect of different ways to group samples, or analyze different subsets
of the data separately). The approach is to keep the data set static,
and provide a separate Structure
instance holding the
description of the structure for each analysis.
There are four ways of creating a Structure
instance. First,
based on group labels of an Align
instance. Second, based on
group labels provided in a list
or another iterable. Third, by
providing the sample size of each population. Fourth, from a more
flexible explicit description of the structure as a dict
instance. Those approaches are described in the next paragraphs.
Group labels¶
To account for structure, EggLib uses a specific system that consists in attaching labels to each samples. All samples bearing the same label are supposed to belong to the same group. There can be several labels per samples. We sometimes refer to an index among group labels as a level. Different levels of structure are aimed to represent either nested levels of structure (clusters of populations and/or populations and/or individuals) or alternative levels of structure. There are little restrictions on group labels besides being strings.
All Align
and Container
instances have a list of
labels attached to each sample. They can be set or edited using either
LabelView
which is a part of SampleView
(see
Principle of proxy types) or direct class-level methods
get_label()
and set_label()
. By default,
this list of labels is empty.
The aim of these group labels is essentially to be interpreted by the
class Structure
(actually through the method from_labels()
)
to be used for computing diversity statistics or restrict an operation
to a given subgroup.
In the following we explain how to specify group labels in Fasta files
such as EggLib will properly interpret them and store them within an
Align
or Container
instance.
Group labels in Fasta files¶
Single group label¶
Let align2.fas
be a Fasta file with six samples, the first three belonging to
population “pop1” and the other three belonging to population “pop2”. EggLib
supports a specific system of tags within sequence headers in the Fasta format
to indicate group labels. The tags must appear as suffix starting with a @
followed by a string, as in the following example:
>sample1@pop1
ACCGTGGAGAGCGCGTTGCA
>sample2@pop1
ACCGTGGAGAGCGCGTTGCA
>sample3@pop1
ACCGTGGAGAGCGCGTTGCA
>sample4@pop2
ACCGTGGAGAGCGCGTTGCA
>sample5@pop2
ACCGTGGAGAGCGCGTTGCA
>sample6@pop2
ACCGTGGAGAGCGCGTTGCA
To import group labels, one is required to set the labels option of
from_fasta()
to True
:
>>> aln2 = egglib.io.from_fasta('align2.fas', alphabet=egglib.alphabets.DNA, labels=True)
>>> print(aln2.get_name(0), aln2.get_label(0, 0))
sample1 1
If labels have not been imported, accessing any label will cause an
error because, by default, there is no group labels at all included in
Align
instances:
>>> aln2 = egglib.io.from_fasta('align2.fas', alphabet=egglib.alphabets.DNA)
>>> print(aln2.get_label(0, 0))
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/stephane/.local/lib/python3.9/site-packages/egglib/_interface.py", line 986, in get_label
v = self._obj.get_label(self._sample(sample), self._label(index, self._sample(sample)))
File "/home/stephane/.local/lib/python3.9/site-packages/egglib/_interface.py", line 827, in _label
if index >= self._obj.get_nlabels(sample): raise IndexError('invalid label index')
IndexError: invalid label index
Note
io.from_fasta()
has an option (label_marker) to use a
different character than @
to separate the name and the tag.
Multiple group labels¶
There can be any number of group levels, either nested or not. To specify several labels for a sample, one can write several strings separated by commas, as in the following example:
>sam1@c1,i1
ACCGTGGAGAGCGCGTTGCA
>sam2@c1,i1
ACCGTGGAGAGCGCGTTGCA
>sam3@c1,i2
ACCGTGGAGAGCGCGTTGCA
>sam1@c1,pop1,i1
ACCGTGGAGAGCGCGTTGCA
>sam5@c2,pop1,i1
ACCGTGGAGAGCGCGTTGCA
>sam6@c2,pop1,i2
ACCGTGGAGAGCGCGTTGCA
The example above also demonstrate that it is possible to omit group labels for part of the samples,
although it is probably better to avoid it (because it is error-prone). Labels
absent for a given level are not added or initialised in any way. As a result, if the
file shown above is saved as align3.fas
we can access the second label
of the first sample as shown in the highlighted line below:
>>> aln3 = egglib.io.from_fasta('align3.fas', alphabet=egglib.alphabets.DNA, labels=True)
>>> print(aln3.get_name(0))
sample1
>>> print(aln3.get_label(0, 0))
c1
>>> print(aln3.get_label(0, 1))
i1
So, at this point, one should understand labels as list of 0, 1 or more
arbitrary identifiers attached to each sample. How this labels will be
used to group samples in individuals, populations or possibly multi-level
hierachical structure is up to the Structure
class.
Note
The separator can also be changed. This can be done using the
label_separator argument of the io.from_fasta()
method.
Outgroup specification¶
Tools analysing diversity in EggLib can account for one or more outgroup samples. If individuals are defined in the main group (ingroup), it is required that outgroup samples also come as one or more individuals sharing the same ploidy.
Individuals must be specified by the label #
(obligatory as the
only or first label), followed by maximum one other label (if the
individual level is to be considered). Thus, outgroup samples are
denoted by the tag @#
or @#,IND
where IND
is an individual
label. Note that if there are individuals in the ingroup then there
must be also individuals in the outgroup (with the same ploidy).
Very importantly, one must keep in mind that neither
io.from_fasta()
nor Align
have a notion of the
outgroup. They don’t interpret the #
label as special and don’t
process outgroup samples differently of other samples. It will be the
job of Structure
to separate the outgroup from the rest of
the samples. This means that, if you have outgroup samples including in
your data, you must use a Structure
instance for treating
them properly. Also, if you want to use another label than #
to
identify outgroup samples, you need to tell it to Structure
.
Creating a structure from an alignment¶
To present the usage of the Structure
class, we will use a
complete, albeit over-simplified example. Consider the Fasta
alignment below:
>sample01@c1,p1,i01
CTTCCGGGAAGCGCCAGCAGAAGGTTGCTGCTAAGGCCCGCACACGTCTGCAGCACTTCG
>sample02@c1,p1,i01
CTTCCGCGCAGGGCCAGGAGCATGTAGCTTCTAAGGCTTGCACAGGTCTTCAGCACTACG
>sample03@c1,p1,i02
CTTCCGCGCAGGGCCAGGAGCATGTAGCTTCTAAGGCTTGCACAGGTCTTCAGCACTACG
>sample04@c1,p1,i02
CTTCCGCGCAGGGCCAGGAGCATGTAGCTTCTAAGGCTTGCACAGGTCTTCAGCACTACG
>sample05@c1,p1,i03
CTACCGTGACGAGCTAGCCGAGCCTGACGCAGGGGGCGAGTAAGGGAGATTACGACTTGG
>sample06@c1,p1,i03
CTTCCGCGCAGGGCCAGGAGAATGTTGCTTCTAAGGCTTGCACAGGTCTTCAGCACTAAG
>sample07@c1,p2,i04
CTGCTATGACGAACTACCCGAGCCTGGGGCATGGGGCGTGTATGGGAGCTTACAACTTGG
>sample08@c1,p2,i04
CTTACGCGACGTGCCAGCATAGGGAAGCTGCTAAGGCCTGCACACGTCCGCAGCACTACG
>sample09@c1,p2,i05
CTGCTATGACGAACTACCCGAGGCTGGGGCATGGGGCGTGTATGGGAGCTTACAACTTGG
>sample10@c1,p2,i05
CTGCTATGACGAACTACCCGAGGCTGGGGCATGGGGCGTGTATGGGAGCTTACAACTTGG
>sample11@c1,p2,i06
CTTACGCGACGCGCCAGCAGAGGGATGCTGCTAAGGCCTGCACACGTCCGCAGCACTACG
>sample12@c1,p2,i06
CTTACGCGACGCGCCAGCAGAGGGATGCTGCTAAGGCCTGCACACGTCCGCAGCACTACG
>sample13@c1,p2,i07
CTGCTATGACGAACTACCCGAGCCTGGGGCATGGGGCGTGTATGGGAGCTTACAACTTGG
>sample14@c1,p2,i07
CTGCTATGACGAACTACCCGAGGCTGGGGCATGGGGCGTGTATGGGAGCTTACAACTTGG
>sample15@c2,p3,i08
CTCCGGGGCCGGTTTCGCATAACGTCGCGCAGGGGACGTGTAGGGGCGCATACACCTGGG
>sample16@c2,p3,i08
CTCCGGGGCCGGTTTCGCATAACGTCGCGCAGGGGACGTGTAGGGGCGCATACACCTGGG
>sample17@c2,p3,i09
TTGCCGGGTCGAACTAGCCGACCTTGGCGCAGGGGTCGTTTAAGGGTCCTTACAACTTGG
>sample18@c2,p3,i09
TTGCCGGGTCGAACTAGCCGACCTTGGCGCAGGGGTCGTTTAAGGGTCCTTACAACTTGG
>sample19@c2,p3,i10
CTCCGGCGCCGGTTTCGCATAACGTCGCGCAGGGGACGTGTAGGGGCGCATACACCTGGG
>sample20@c2,p3,i10
TTGCCGGGTCGAACTAGCCGACCTTGGCGAAGGGGTCGTTTAAGGGACCTTACAACTTGG
>sample21@c2,p4,i11
TTGCCAGGACGAACTAGCCGCGCCTGGCGCAGGGGTCGTTTAAGGGAGCTTACAACTTGG
>sample22@c2,p4,i11
TTGCCAGGACGAACTAGCCGCGCCTGGCGCAGGGGTCGTTTAAGGGAGCTTACAACTTGG
>sample23@c2,p4,i12
TTGCCGGGACGAACTAGCCGAGCCTGGCGCAGGGGTCGTTTAAGGGAGCTTACAACTTGG
>sample24@c2,p4,i12
TTGCCGGGACGAACTAGCCGAGCCTGGCGCAGGGGTCGTTTAAGGGAGCTTACAACTTGG
>sample25@c2,p5,i13
CTACCGTGACGAACTAGCCGAGCCTGGCGCAGGGGGCGAGTAAGGGAGAGTACAACTTGG
>sample26@c2,p5,i13
CTACCGTGACGAACTAGCCGAGCCTGGCGCAGGGGGCGAGTAAGGGAGAGTACAACTTGG
>sample27@c2,p5,i14
CTACCGTGACGAACTAGCCGAGCCTGGCGCAGGGGGCGAGTAAGGGAGAGTACAACTTGG
>sample28@c2,p5,i14
CTACCGTGACGAACTAGCCGAGCCTGGCGCAGGGGGCGAGTAAGGGAGAGTACAACTTGG
>sample29@c2,p5,i15
TTGCCGCGACGAACTAGCCGAGCCTGGCGCAGGGGTCGTTTAAGGGAGCTAACAACTTGG
>sample30@c2,p5,i15
CTACCGTGACGAACTAGCCGAGCCTGGCGCAGGGGGCGAGTAAGGGAGAGTACAACTTGG
>sample31@#,i98
CATACCACCTTGGCCCGGAGAGTGCGGAGTACCGGGCGTGGAAGGCTGCATGCAAATGGA
>sample32@#,i98
CATACCACCTTGGCCCGGAGAGTGCGGAGTACCGGGCGTGGAAGGCTGCATGCAAATGGA
>sample33@#,i99
CATACCACCTTGGCCCGGAGAGAGCGCAGTGCCGGGCGTGGAAGGCTGCATTCAAATGCG
>sample34@#,i99
CATACCACCTTGGCCCGGAGAGAGCGCAGTGCCGGGCGTGGAAGGCTGCATTCAAATGCG
It has a total of 30 ingroup samples and 4 outgroup samples. These are
actually respectively 15 and 2 individuals, and the ingroup is organized
in two clusters of respectively two and three populations, themselves composed of two,
three, or four individuals each. Remember the labels are arbitrary. In this case,
cluster labels are c1
and c2
, population labels p1
to p5
and individual labels i01
to i15
(i98
and i99
for the
two outgroup individuals).
Let use name this file align5.fas
and import it with group labels:
>>> aln = egglib.io.from_fasta('align5.fas', alphabet=egglib.alphabets.DNA, labels=True)
Now, we will directly show a Structure
instance incorporating
all structure information (all three levels) can be created:
>>> struct = egglib.struct_from_labels(aln, lvl_clust=0, lvl_pop=1, lvl_indiv=2)
>>> print(struct.as_dict())
({'c2': {'p3': {'i09': [16, 17], 'i08': [14, 15], 'i10': [18, 19]},
'p5': {'i13': [24, 25], 'i15': [28, 29], 'i14': [26, 27]},
'p4': {'i11': [20, 21], 'i12': [22, 23]}},
'c1': {'p1': {'i01': [0, 1], 'i03': [4, 5], 'i02': [2, 3]},
'p2': {'i05': [8, 9], 'i04': [6, 7], 'i07': [12, 13], 'i06': [10, 11]}}},
{'i99': [32, 33], 'i98': [30, 31]})
We used the function struct_from_labels()
that generates a new
Structure
instance based on group labels of an Align
(or Container
). To use this method, it is necessary to tell
which group level corresponds to the clusters, populations, and individuals
in such a way that they are properly hierarchical. It is possible to skip
any of these three levels of structure, simply by dropping the corresponding
option parameter(s).
The method as_dict()
is aimed to provide an intuitive
representation of the structure held by the instance. In practice, it is as
intuitive as possible while being flexible enough to represent all possible cases.
Dictionary representation of Structure
instances¶
It is a tuple
containing two items,
each being a dict
. The first one represents the ingroup and the second
represents the outgroup.
The ingroup dictionary is itself a dictionary holding more dictionaries, one for each cluster of populations. Each cluster dictionary is a dictionary of populations, populations being themselves represented by a dictionary. A population dictionary is, again, a dictionary of individuals. Finally individuals are represented by lists or integers.
An individual list contains the index
of all samples belonging to this individual. For haploid data, individuals
will be one-item lists. In other cases, all individual lists are required to have
the same number of items (consistent ploidy). Note that, if the ploidy is more
than one, nothing enforces that samples of a given individual are grouped within
the original data, meaning that you can shuffle labels in Align
instances
(or in your Fasta file) if you need to.
The keys of the ingroup dictionary are the labels identifying each cluster. Within a cluster dictionary, the keys are population labels. Finally, within a population dictionary, the keys are individual labels.
The second dictionary represents the outgroup. Its structure is simpler: it has individual labels as keys, and lists of corresponding sample indexes as values. The outgroup dictionary is similar to any ingroup population dictionary. The ploidy is required to match over all ingroup and outgroup individuals.
If we go back to our example, we see that the returned dictionary for the
ingroup has two items, with keys c1
and c2
, respectively, and that
the correct structure appears at lower levels, with two-item (diploid) individuals
within populations withing clusters. Similarly, the two outgroup individuals,
labelled i98
and i99
, appear as expected in the second dictionary returned by
the as_dict()
method. Ultimately, the values contained by the
lists are the lowest levels are the index referring to the original
Align
instance (from 0 to 29 in the ingroup, 30 to 33 in the
outgroup).
Alternative structure¶
Occasionally, one will want to generate different Structure
instances
based on different levels of structure in group labels (for example if there are
alternative structurations of the data). It is not required that all levels of
a Structure
instances are populated, and it is not necessary to import
all structure levels of an Align
. The example below demonstrates all
this by importing the first level (previously, clusters) as populations in a new
instance, skipping all other information:
>>> struct2 = egglib.struct_from_labels(aln, lvl_pop=0)
>>> print(struct2.as_dict())
({None: {'c2': {'24': [24], '25': [25], '23': [23], '27': [27],
'15': [15],'14': [14], '17': [17], '16': [16],
'19': [19], '18': [18], '22': [22], '28': [28],
'26': [26], '29': [29], '20': [20], '21': [21]},
'c1': {'11': [11], '10': [10], '13': [13], '12': [12],
'1': [1], '0': [0], '3': [3], '2': [2], '5': [5],
'4': [4], '7': [7], '6': [6], '9': [9], '8': [8]}}},
{'33': [33], '32': [32], '31': [31], '30': [30]})
Note that it is also possible to recycle an already existing Structure
instance instead creating a new one (with the method from_labels()
of Structure
instances.).
Since we did not specify any group label index for the cluster level, there is
no information regarding clusters, and all populations are placed in a single
cluster. The default label is None
in that case. The two labels c1
and c2
are
now considered as population labels. At the lowest level (also in the outgroup),
all samples are placed in a single-item individuals because, likewise, no index
has been provided for the individual level. Then, haploidy is assumed, and the
sample index is used as default value for individual labels (incremented in the
outgroup).
This example demonstrates that the group labels in Align
instances have no particular meaning per se until they are interpreted
while configuring a Structure
instance.
Passing labels directly¶
Labels are not required to be included in a Fasta file. They can be
passed as a list
(or other iterable) of labels (or
list
/tuple
of labels) to create a
structure
instance. This is done using the
struct_from_iterable()
funtion. If a single label is passed, it
is treated as a population label. If several labels are passed (as in
the example below), the argument fmt must be used to specify the level
represented by each column of the label table. The limitation is that
this method doesn’t allow to import ingroup. If the Structure
instance created by the example below is used, samples corresponding to
outgroups, which are not included in the Structure
, will be
ignored altogether (because Structure
are not required to
represent all samples of genetic data objects):
>>> labels = [
... ('c1', 'p1', 'i01'),
... ('c1', 'p1', 'i01'),
... ('c1', 'p1', 'i02'),
... ('c1', 'p1', 'i02'),
... ('c1', 'p1', 'i03'),
... ('c1', 'p1', 'i03'),
... ('c1', 'p2', 'i04'),
... ('c1', 'p2', 'i04'),
... ('c1', 'p2', 'i05'),
... ('c1', 'p2', 'i05'),
... ('c1', 'p2', 'i06'),
... ('c1', 'p2', 'i06'),
... ('c1', 'p2', 'i07'),
... ('c1', 'p2', 'i07'),
... ('c2', 'p3', 'i08'),
... ('c2', 'p3', 'i08'),
... ('c2', 'p3', 'i09'),
... ('c2', 'p3', 'i09'),
... ('c2', 'p3', 'i10'),
... ('c2', 'p3', 'i10'),
... ('c2', 'p4', 'i11'),
... ('c2', 'p4', 'i11'),
... ('c2', 'p4', 'i12'),
... ('c2', 'p4', 'i12'),
... ('c2', 'p5', 'i13'),
... ('c2', 'p5', 'i13'),
... ('c2', 'p5', 'i14'),
... ('c2', 'p5', 'i14'),
... ('c2', 'p5', 'i15'),
... ('c2', 'p5', 'i15')]
>>> struct = egglib.struct_from_iterable(labels, fmt='CPI')
>>> print(struct.as_dict())
({'c1': {'p1': {'i01': [0, 1], 'i02': [2, 3], 'i03': [4, 5]},
'p2': {'i04': [6, 7], 'i05': [8, 9], 'i06': [10, 11], 'i07': [12, 13]}},
'c2': {'p3': {'i08': [14, 15], 'i09': [16, 17], 'i10': [18, 19]},
'p4': {'i11': [20, 21], 'i12': [22, 23]}, 'p5': {'i13': [24, 25], 'i14': [26, 27], 'i15': [28, 29]}}},
{})
Simple structure¶
If your data are organized in an intuitive way (that is, samples organized
per individual and individuals grouped per population), and if the cluster
level is not needed, you can use the function struct_from_samplesizes()
.
This function takes a list of sample sizes (one item per population).
For example, if your dataset contains two populations of 20 haploid
individuals, you can enter simply:
>>> struct = egglib.struct_from_samplesizes([20, 20])
>>> print(struct.as_dict())
({None: {'pop1': {'idv1': [0], 'idv2': [1], 'idv3': [2], 'idv4': [3],
'idv5': [4], 'idv6': [5], 'idv7': [6], 'idv8': [7],
'idv9': [8], 'idv10': [9], 'idv11': [10],'idv12': [11],
'idv13': [12], 'idv14': [13], 'idv15': [14], 'idv16': [15],
'idv17': [16], 'idv18': [17], 'idv19': [18], 'idv20': [19]},
'pop2': {'idv21': [20], 'idv22': [21], 'idv23': [22], 'idv24': [23],
'idv25': [24], 'idv26': [25], 'idv27': [26], 'idv28': [27],
'idv29': [28], 'idv30': [29], 'idv31': [30], 'idv32': [31],
'idv33': [32], 'idv34': [33], 'idv35': [34], 'idv36': [35],
'idv37': [36], 'idv38': [37], 'idv39': [38], 'idv40': [39]}}}, {})
This function supports ploidy and outgroup individuals, so you can also declare, for example, two populations of 10 diploid individuals plus one outgroup individual:
>>> struct = egglib.struct_from_samplesizes([10, 10], ploidy=2, outgroup=1)
>>> print(struct.as_dict())
({None: {'pop1': {'idv1': [0, 1], 'idv2': [2, 3], 'idv3': [4, 5],
'idv4': [6, 7], 'idv5': [8, 9], 'idv6': [10, 11],
'idv7': [12, 13], 'idv8': [14, 15], 'idv9': [16, 17],
'idv10': [18, 19]},
'pop2': {'idv11': [20, 21], 'idv12': [22, 23], 'idv13': [24, 25],
'idv14': [26, 27], 'idv15': [28, 29], 'idv16': [30, 31],
'idv17': [32, 33], 'idv18': [34, 35], 'idv19': [36, 37],
'idv20': [38, 39]}}}, {'idv21': [40, 41]})
Be careful that the order of samples in the Align
or
Site
you’ll be analyzing with the resulting Structure
instance must be consistent. Populations must be grouped together in the
order indicated (if sample sizes differ), as well as individuals in
populations, and the outgroup must be at the end.
Fully flexible dictionary¶
It is possible to create a Structure
instance from
user-provided data formatted as dictionaries, using either the function
struct_from_dict()
or the equivalent method
of Structure
instances to recycle an
existing instance. This approach allows maximal flexibility but
requires that you create a properly formatted dictionary. Both methods
take an ingroup and an outgroup argument, which are formatted
exactly as the output of as_dict()
(see Dictionary representation of Structure instances).
This feature can be used to import complex structure information.
Using the structure¶
Once a Structure
has been configured to represent the structuration
of the data set, it can be used as a descriptor while computing diversity
statistics. This will make available a wide array of statistics requiring
this type of information. For example, the statistics with codes
Fis
, Gst
, WCist
, and WCisct
require individual and/or
population structure information and won’t be computed if no structure
is provided:
>>> cs = egglib.stats.ComputeStats()
>>> cs.add_stats('Fis', 'FistWC', 'FisctWC', 'Gst')
>>> print(cs.process_align(aln))
{'Gst': None, 'Fis': None, 'WCisct': None, 'WCist': None}
To provide the Structure
to stats.ComputeStats
, one
just needs to pass the instance as a value for the struct argument of
the class constructor (or the configure()
method of ComputeStats
instances or alternatively, their
set_structure()
method):
>>> cs.configure(struct=struct)
>>> print(cs.process_align(aln))
{'FisctWC': (0.39885944313988575, 0.4964142771064885, 0.2339234438730581, 0.6972741981129913),
'Gst': 0.42684652746367224, 'Fis': 0.6361192023302706,
'FistWC': (0.39885944313988586, 0.4485871173702674, 0.6685233526761218)}
The code above shows that, with proper structure, we can compute statistics
taking into account the individual, population, and cluster levels. In particular,
FisctWC
takes all levels into account. In comparison, FistWC
ignores
the cluster level, but nothing prevents you from computing it at this point.
The code below shows that we can analyse the same data with a different structure
(using the second instance we created before, using the clusters as populations
and ignoring other levels):
>>> cs.configure(struct=struct2)
>>> print(cs.process_align(aln))
{'Fis': None, 'FistWC': None, 'Gst': 0.1987618106564927, 'FisctWC': None}
Since the individual level is not available, the statistics Fis
,
WCist
, and WCisct
(which also requires the cluster level) cannot
be computed. Only Gst
can. It is still possible to call for statistics
that cannot be computed, but their value will be set to None
.