2024年1月

前言

书接上回,上回我们了解了 castle 代理的一些缺点,本文将开始操作整合 Microsoft.Extension.Dependency和Castle,以让默认的容器可以支持拦截器
我们将以进阶的形式逐步完善我们的封装,以实现一个更方便易用、普适、高性能的基础设施库。

基础版

还是先上代码, 这是基础版本我们要达成的目标,仅需定义一个特性即可完成拦截的目标

/// <summary>
/// 
/// </summary>
public abstract class InterceptorBaseAttribute : Attribute, IInterceptor
{
    void IInterceptor.Intercept(IInvocation invocation)
    {
        var returnType = invocation.Method.ReturnType;
        var builder = AsyncMethodBuilder.TryCreate(returnType);
        if (builder != null)
        {
            var asyncInvocation = new AsyncInvocation(invocation);
            var stateMachine = new AsyncStateMachine(asyncInvocation, builder, task: InterceptAsync(asyncInvocation));
            builder.Start(stateMachine);
            invocation.ReturnValue = builder.Task();
        }
        else
        {
            Intercept(invocation);
        }
    }

    protected virtual void Intercept(IInvocation invocation) { }

    protected abstract ValueTask InterceptAsync(IAsyncInvocation invocation);
    ......
}

如上是我们定义的拦截器基类,我们想要达到的目标是,只要继承该基类,并覆写InterceptAsync 方法即可实现具有特定功能的拦截类,而容器会自动代理到该拦截类,实现拦截。
这里要感谢
https://github.com/stakx/DynamicProxy.AsyncInterceptor
的作者,该库采用
MIT
的许可使用协议,我们可以直接参考使用。
接下来,是重头戏,考虑到易用性,我们以 Microsoft.Extension.DependencyInjection 为基本库,实现一个扩展类,用于实现拦截器功能。
代码如下:

public static class CastleServiceCollectionExtensions
{
    public static IServiceCollection ConfigureCastleDynamicProxy(this IServiceCollection services)
    {
        services.TryAddSingleton<ProxyGenerator>(sp => new ProxyGenerator());
        //TODO:1.从IServiceCollection中获取 方法定义InterceptorBaseAttribute特性子类的ServiceDescriptor

        //TODO:2.逐个处理,获取每个ServiceDescriptor中的ServiceType,识别是具体类还是接口,然后获取InterceptorBaseAttribute特性子类的实例
        //作为拦截器,借用proxyGenerator 去创建对应的代理然后添加到IServiceCollection中

        //TODO:3 移除原始对应的ServiceType注册
        return services;
    }
}

在注释中我们简单描述了该扩展方法的实现过程,我们采用移花接木的方式替换掉原有ServiceType的注册,将代理对象注册为ServiceType的实现即可。

第一步我们这么实现

var descriptors = services.Where(svc =>svc.ServiceType.GetMethods()
    .Any(i => i.GetCustomAttributes(false).Any(i => i.GetType().IsAssignableTo(typeof(InterceptorBaseAttribute))))).ToList();

第二步的核心是
ServiceDescriptor
中 三种生成场景的分开处理,至于是哪三种场景可以看下我的第一篇文章
https://www.cnblogs.com/gainorloss/p/17961153

  • descriptor.ImplementationType 有值:已知ServiceType和ImplementationType
    伪代码如下
implementationFactory = sp =>
{
    var generator = sp.GetRequiredService<ProxyGenerator>();

    var interceptors = GetInterceptors(descriptor.ServiceType);//获取拦截器 galoS@2024-1-12 14:47:47

    var proxy = descriptor.ServiceType.IsClass
    ? generator.CreateClassProxy(descriptor.ServiceType,  interceptors.ToArray())
    : generator.CreateInterfaceProxyWithoutTarget(descriptor.ServiceType, interceptors.ToArray());
    return proxy;
};
  • descriptor.ImplementationInstance 有值:已知ServiceType和 实现对象实例
implementationFactory = sp =>
{
    var generator = sp.GetRequiredService<ProxyGenerator>();
    var interceptors = GetInterceptors(descriptor.ServiceType, sp);//获取拦截器 galoS@2024-1-12 14:47:47
    var proxy = descriptor.ServiceType.IsClass
    ? generator.CreateClassProxyWithTarget(descriptor.ServiceType, descriptor.ImplementationInstance, interceptors.ToArray())
    : generator.CreateInterfaceProxyWithTarget(descriptor.ServiceType, descriptor.ImplementationInstance, interceptors.ToArray());
    return proxy;
};
  • descriptor.ImplementationFactory 有值:已知ServiceType和 实现工厂方法
implementationFactory = sp =>
{
    var generator = sp.GetRequiredService<ProxyGenerator>();
    var interceptors = GetInterceptors(descriptor.ServiceType, sp);//获取拦截器 galoS@2024-1-12 14:47:47
    var proxy = descriptor.ServiceType.IsClass
    ? generator.CreateClassProxyWithTarget(descriptor.ServiceType, descriptor.ImplementationInstance, interceptors.ToArray())
    : generator.CreateInterfaceProxyWithTarget(descriptor.ServiceType, descriptor.ImplementationInstance, interceptors.ToArray());
    return proxy;
};

可以看到 2,3比较雷同,因为拿到 实例和通过委托传入IServiceProvider拿到实例,其实结果是相似的,最终我们都使用工厂注入的形式 生成新的 ServiceDescriptor
services.AddTransinet(descriptor.ServiceType, implementationFactory);

最后一步 移除即可
伪代码如下
services.Remove(descriptor);

改造一下之前的代码并测试

 var services = new ServiceCollection();
services.AddLogging();//此处添加日志服务 伪代码 以便获取ILogger<SampleService>
services.TryAddTransient<SampleService>();
services.TryAddTransient<ISampleService, SampleService>();
services.ConfigureCastleDynamicProxy();//一定要在最后,不然会有些服务无法代理到 2024-1-13 13:53:05
var sp = services.BuildServiceProvider();

var proxy = sp.GetRequiredService<SampleService>();
var name = await proxy.ShowAsync();

/// <summary>
/// 异常捕获、日志记录和耗时监控 拦截器 2024-1-12 21:28:22
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public class CatchLoggingInterceptor : InterceptorBaseAttribute
{
   protected override async ValueTask InterceptAsync(IAsyncInvocation invocation)
   {
       //TODO:类注释所写的逻辑
       await Console.Out.WriteLineAsync("Interceptor  starting...");
       Console.WriteLine("Interceptor  starting...");
       await invocation.ProceedAsync();
       await Console.Out.WriteLineAsync("Interceptor  ended...");
   }
}

运行如下
image
,
image

可以看到拦截器这时候是异步方法,并且可以明显看到注入被简化了。
大家可以考虑下为什么
services.ConfigureCastleDynamicProxy()
一定要在BuildServiceProvider()之前,其他注入之后

进阶版本

进阶 版本这里我们不再详细描述,直接看源码
https://gitee.com/gainorloss_259/microsoft-castle.git
主要解决的问题是castle 拦截器不支持 依赖ioc的服务
使用伪代码如下

public class SampleService : ISampleService
{
   [CatchLoggingInterceptor]
   [Interceptor(typeof(LoggingInterceptor))]//第二种使用方式
   public virtual Task<string> ShowAsync()
   {
       Console.WriteLine(nameof(ShowAsync));
       return Task.FromResult(nameof(ShowAsync));
   }
}
//定义拦截器
internal class LoggingInterceptor : InterceptorBase
{
   private readonly ILogger<LoggingInterceptor> _logger;

   public LoggingInterceptor(ILogger<LoggingInterceptor> logger)
   {
       _logger = logger;
   }
   protected override async ValueTask InterceptAsync(IAsyncInvocation invocation)
   {
       await Console.Out.WriteLineAsync(nameof(LoggingInterceptor));
       await invocation.ProceedAsync();
   }
}

总结

以上 整合的核心方案及细节已经介绍完毕了,接下来有时间的话可以出一篇对本整合做性能测试的文章;
AOP
是一个很强大的东西,我们基本已经完成了一个比较普适、比较易用的aop底层整理。接下来我们可以做很多东西,比如 事务拦截器、幂等拦截器、重试拦截器、缓存拦截器等等
打好基础,后续可以自己去实现。
这里还有几个问题 ,大家可以思考下

  1. 我们如何能整合两种拦截器 既可以传一些常量又不影响我们的服务注入拦截器
  2. 拦截器是否可以再套用拦截器
  3. 假设我们再日志拦截器上打了日志拦截器 会怎么样

这些都是一些比较有意思的问题,相信这些问题的思考会让大家对动态代理的理解更深,并可以灵活的将其用到自己的项目中。

源码及声明

当前示例代码已传至
https://gitee.com/gainorloss_259/microsoft-castle.git
如转载请注明出处,谢谢!

如果需要其他图像处理的文章及代码,请移步小编的GitHub地址

传送门:
请点击我

如果点击有误:https://github.com/LeBron-Jian/ComputerVisionPractice

本来在前面博客
OpenCV计算机视觉学习(2)——图像算术运算 &图像阈值(数值计算,掩膜mask操作,边界填充,二值化
)里面已经学习了图像的数值计算,即常量加减等。但是在C++中和python使用不同的方式进行常量计算还是有一点点的区别,比如说python的numpy类型的运算符操作是取模操作,但是opencv的运算符操作却是饱和运算。当然opencv的cv2.add函数在C++和python是一致的。于是我这里将自己认为重要的点梳理一下。

1,什么是饱和运算,什么是取模运算

饱和运算(Saturating Arithmetic)和取模运算(Modulo Operation)是两种不同的数学运算。

1.1 饱和运算(Saturating Arithmetic)

定义
:在计算机图像处理和信号处理中,饱和运算是一种处理溢出的方法。当进行某些运算(例如加法或乘法)时,结果可能会超出数据类型的表示范围,导致溢出。饱和运算就是在发生溢出时,将结果限制在数据类型的最大和最小值之间(通常是通过截断或设置上下界),而不是简单地截断或取模。

具体来说,对于无符号数据类型,饱和运算会将溢出的结果设置为该数据类型的最大值;对于有符号数据类型,饱和运算会将溢出的结果设置为该数据类型的最大正值或最小负值,以保持在有符号范围内。

示例: 在图像处理中,对于8位无符号整数(uchar)的像素值,其范围是0到255。饱和加法将确保结果在0到255之间。如果相加的结果大于255,饱和加法会将结果截断为255,类似地,如果结果小于0,饱和运算将结果设置为0。

1.2 取模运算(Modulo Operation)

定义
: 取模运算是指对两个整数相除,返回余数的运算。通常使用符号“%”表示。对于整数a和正整数b,a % b 的结果是一个非负整数,其大小小于b。

示例: 在图像处理中,取模运算常用于周期性的操作,如周期性的亮度变化。对于像素值的取模加法,可以将结果限制在一个范围内,例如对256取模,确保结果在0到255之间。

总体而言,饱和运算用于控制结果的范围,防止溢出,而取模运算用于获取除法的余数,通常应用于周期性的操作。在图像处理中,这两种运算都有其应用场景,具体取决于需要实现的效果。具体下面来说。

2,在图像处理中,饱和运算和取模运算的区别,联系,应用场景分别是什么?

在图像处理中,饱和运算和取模运算都可以用于对图像像素值的调整,但它们的应用场景和效果略有不同。
饱和运算(Saturating Arithmetic)
特点

饱和运算主要用于防止溢出,确保结果在一个合理的范围内,通常是0到255。
对于8位无符号整数(uchar)的像素值,饱和加法会将结果限制在0到255之间,超过255的部分会被截断为255,保持在合法范围内。
应用场景

饱和运算常用于图像亮度调整、滤波等场景,确保处理后的像素值不超出可表示的范围。
取模运算(Modulo Operation)
特点

取模运算主要用于周期性的操作,将结果限制在一个周期内,通常是对256取模,确保结果在0到255之间。
取模运算可以用于模拟周期性的光照变化、颜色循环等效果。
应用场景

取模运算常用于需要产生循环或周期性效果的图像处理,例如通过周期性调整图像的亮度、对比度或颜色,以实现动态的视觉效果。
联系:
饱和运算和取模运算都是对结果进行限制的方式,确保结果在某个特定范围内。
在某些情况下,可以结合使用这两种运算,根据具体需求综合考虑。
总体来说:

1,如果你希望避免结果溢出,使图像保持在一个可接受范围内,使用饱和运算。

2,如果你希望实现周期性的效果,例如循环的光照变化或颜色变换,使用取模运算。

3,实际应用中,饱和运算和取模运算的选择取决于具体的图像处理任务和期望的视觉效果

3,以C++和Python 的具体实例测试

3.1 python实现饱和运算和取模运算

python 示例如下(以加法为例,当然你也可以测试减法,乘法等):

import numpy as np

# 初始化两个像素点的值
pixel_a = np.uint8([150])
need_to_add_pixel = np.uint8([120])

# 饱和运算:将数值限制在一定范围内,通常是0~255之间
# 在图像处理中,这用于确保像素不会超出表示颜色的范围,例如某个像素的计算结果超出255,则被饱和到255
# 150+120 = 270 => 255
print(cv2.add(pixel_a, need_to_add_pixel))
# 打印结果为:[[255]]

# 取模运算:计算两个数相除的余数
# 在图像处理中,取模运算可以用于创建循环效果,例如在图像边缘处形成循环纹理
# 250+10 = 260 % 256 = 4
print(pixel_a + need_to_add_pixel)
# 打印结果为: [14]

我将python的结果和过程解释都写在代码中了,实际上确实opencv实现的常量运算是饱和运算。而运算符实现的常量运算是取模运算。下面再看C++的。

3.2 C++实现饱和运算和取模运算

C++示例如下:

    // 创建两个单像素的Mat,像素值分别为170和190
    cv::Mat pixel1(1, 1, CV_8UC1, cv::Scalar(170));
    cv::Mat pixel2(1, 1, CV_8UC1, cv::Scalar(190));

    // 创建两个单像素的uchar,像素值分别是200和210
    uchar pixel3 = 200;
    uchar pixel4 = 210;

    std::cout << "Pixel1 value: " << static_cast<int>(pixel1.at<uchar>(0, 0)) << std::endl;
    std::cout << "Pixel2 value: " << static_cast<int>(pixel2.at<uchar>(0, 0)) << std::endl;
    std::cout << "Pixel3 value: " << static_cast<int>(pixel3) << std::endl;
    std::cout << "Pixel4 value: " << static_cast<int>(pixel4) << std::endl;
    std::cout << "Data type of pixel1: " << typeid(pixel1).name() << std::endl;
    std::cout << "Data type of pixel2: " << typeid(pixel2).name() << std::endl;
    std::cout << "Data type of pixel3: " << typeid(pixel3).name() << std::endl;
    std::cout << "Data type of pixel4: " << typeid(pixel4).name() << std::endl;

    // 使用 cv::add 进行饱和运算
    cv::Mat result_add_saturate12;
    cv::add(pixel1, pixel2, result_add_saturate12);

    // 使用 + 运算符进行溢出运算
    cv::Mat result_add_overflow12 = pixel1 + pixel2;
    uchar result_add_overflow34 = pixel3 + pixel4;

    // 输出结果
    std::cout << "Result12 using cv::add: " << static_cast<int>(result_add_saturate12.at<uchar>(0, 0)) << std::endl;
    std::cout << "Result12 using + operator: " << static_cast<int>(result_add_overflow12.at<uchar>(0, 0)) << std::endl;
    std::cout << "Result34 using + operator: " << static_cast<int>(result_add_overflow34) << std::endl;

结果如下:

Pixel1 value: 170
Pixel2 value: 190
Pixel3 value: 200
Pixel4 value: 210
Data type of pixel1: class cv::Mat
Data type of pixel2: class cv::Mat
Data type of pixel3: unsigned char
Data type of pixel4: unsigned char
Result12 using cv::add: 255
Result12 using + operator: 255
Result34 using + operator: 154

但是C++中,我发现如果类型为cv::mat,无论是进行cv::add还是直接使用加法运算符,总是进行饱和操作。而不进行取模操作。但是如果对数据类型设置为uchar,然后使用加法运算符,则结果就是取模运算。

3.3 讨论:为什么opencv的add是饱和运算,而numpy的加法却写成取模

OpenCV的cv::add和NumPy中的加法在设计时可能有不同的考虑,导致了它们在溢出处理上的差异。
OpenCV的 cv::add
cv::add 函数在图像处理中默认采用饱和运算。这是由于在图像处理领域,特别是对于8位无符号整数(uchar)表示的像素值,饱和运算是一种常见的保护手段。饱和运算确保结果不会溢出范围(通常是0到255),防止图像亮度等调整操作导致不可预知的结果。

OpenCV在处理图像时更注重保持图像的可视性,因此默认情况下选择了饱和运算。

NumPy的加法
NumPy是一个通用的数学库,广泛用于科学计算和数组操作,不仅仅是图像处理。NumPy的加法操作默认采用取模运算,这是因为在通用的数学运算中,取模操作更为常见。

在科学计算中,溢出通常表示一个错误,而取模操作则可以使结果在一定范围内循环,更适合一些数学和统计的应用。

虽然OpenCV和NumPy在处理图像时采用了不同的默认溢出处理策略,但两者都提供了灵活的参数选项,允许用户指定其他的溢出处理方法。在OpenCV中,你可以使用cv::addWeighted来实现一定程度上的取模运算;而在NumPy中,你可以使用numpy.remainder函数来实现类似的效果。

总体来说,这种差异主要是由于库设计时的偏好和目标应用的不同。在实际使用中,你可以根据具体需求选择适当的库和参数。

3.4 为什么Opencv要做饱和操作

OpenCV选择使用饱和运算而不是取模运算,主要是因为饱和运算能够更好地处理图像处理任务中的边界情况和避免出现意外的结果。下面是一些理由:

  1. 物理解释
    : 在图像处理中,像素值通常被解释为光强度或颜色强度。对于灰度图像,典型的像素值范围是 [0, 255],代表黑到白的强度。超出这个范围的值在物理上没有明确的解释。

  2. 数学稳定性
    : 饱和运算确保在进行数学运算时,结果始终保持在合理的范围内,避免了溢出引起的不稳定性。在图像处理算法中,保持数学的稳定性对于正确的输出非常重要。

  3. 避免失真
    : 取模运算可能导致图像失真,因为它不会模拟实际图像处理中的物理行为。在处理图像时,饱和运算更符合图像处理任务的实际需求。

  4. 避免伪影
    : 取模运算可能导致伪影(artifacts),因为回绕到0可能导致图像中出现意外的亮度变化。饱和运算避免了这样的问题。

总的来说,OpenCV选择饱和运算是为了确保在图像处理中获得可靠和直观的结果。取模运算通常更适用于某些特定的应用场景,例如密码学等,而不是图像处理领域。

4,C++中opencv的CV_8U类型,CV_8UC1类型,Uchar类型等笔记

4.1 CV_8U类型

在OpenCV中,
CV_8U
是一种图像数据类型,表示图像中的每个像素值为8位无符号整数(8-bit Unsigned)。在这种数据类型下,每个像素的取值范围为0到255。

具体来说,
CV_8U
表示一个8位无符号整数的图像。这种图像类型通常用于表示灰度图像,其中每个像素的亮度值在0到255之间,0表示最暗,255表示最亮。

以下是使用
CV_8U
数据类型创建一个简单的灰度图像的示例:

    // 创建一个单通道的8位无符号整数图像,大小为 100x100
    cv::Mat grayscaleImage(100, 100, CV_8U, cv::Scalar(128));

4.2 CV_8UC1类型

CV_8UC1
是OpenCV中用于表示8位无符号整数单通道图像的数据类型标识。这个标识的含义如下:

  • CV_8U
    :表示8位无符号整数(uchar),像素值范围为 [0, 255]。
  • C1
    :表示单通道,即灰度图像。

因此,
CV_8UC1
表示单通道的8位无符号整数图像,通常用于表示灰度图像,其中每个像素的值是一个8位无符号整数。例如,以下是创建一个单通道的8位无符号整数图像的示例:

cv::Mat grayImage(100, 100, CV_8UC1, cv::Scalar(0));

这将创建一个100x100的灰度图像,所有像素的初始值为0。

4.3 uchar 类型

uchar类型不是C++标准库中的类型,相反,C++标准库使用了 unsigned char类型。

定义
:unsigned char 是一个整数数据类型,用于存储无符号(非负)的字符值,在C++中,unsigned char 通常用于表示字节,范围是0~255之间。

取值范围
:unsigned char类型是一个1字节的整数类型,其范围是从0~255之间(包括0和255)。因为它是无符号类型,所以它不能表示负数,但可以表示0~255之间的所有整数。

如何打印
:你可以使用 std::cout 来打印 unsigned char 的值:

unsigned char ucharValue = 200;
std::cout << static_cast<int>(ucharValue) << std::endl;;

对于创建的一个uchar类型的 ucharVaule,我们通过将其转换为int并打印。

4.4 CV_8U类型和CV_8UC1类型的区别是什么

在OpenCV中,
CV_8U

CV_8UC1
表示图像矩阵的数据类型,但它们之间存在一些区别:

  1. CV_8U:


    1. CV_8U
      表示8位无符号整数。这种数据类型通常用于表示图像中的像素值。

    2. CV_8U
      类型的矩阵中,每个像素值都是一个无符号字节(0 到 255),表示图像的亮度。
  2. CV_8UC1:


    1. CV_8UC1
      表示8位无符号整数,且矩阵只有一个通道(channel)。这是灰度图像的常见数据类型。

    2. CV_8UC1
      类型的矩阵中,每个元素表示一个像素的亮度值,而且图像只有一个通道。

总的来说,
CV_8U
表示一个通用的8位无符号整数类型,而
CV_8UC1
表示一个8位无符号整数类型的矩阵,且该矩阵只有一个通道。如果你处理的是灰度图像,通常会使用
CV_8UC1
类型的矩阵。如果处理的是彩色图像,可能会使用
CV_8UC3
(表示三个通道的8位无符号整数类型)等。

如果你使用
cv::Mat

at<uchar>(i, j)
打印出来的结果是全零,可能是因为
cv::getStructuringElement
返回的矩阵是
CV_8U
类型,而不是
CV_8UC1


CV_8U
类型的图像中,元素的值被认为是无符号字节(unsigned byte),而不是灰度值。这可能导致
at<uchar>
访问失败。

你可以尝试使用
at<int>
来访问元素,或者使用
static_cast<uchar>
进行转换。这里是一种可能的修改:

void printStructuringElement(const cv::Mat& kernel) {
    for (int i = 0; i < kernel.rows; ++i) {
        for (int j = 0; j < kernel.cols; ++j) {
            std::cout << static_cast<int>(kernel.at<uchar>(i, j)) << " ";
        }
        std::cout << std::endl;
    }
    std::cout << std::endl;
}

这将确保uchar类型的元素被正确的转换并打印。

在OpenCV中,
CV_8U

CV_8UC1
都表示8位无符号整数类型。其实,它们的存储方式是相同的,都是使用
uchar
(无符号字符,即
uint8_t
)来存储每个像素的值。在内存中,它们都是占用一个字节。

总体来说,实际上两者是相同的数据类型,都是以
uchar
存储的无符号8位整数。在实际应用中,你可以根据需要选择使用
CV_8U

CV_8UC1
,并根据情况是否需要进行强制转换来正确打印。

// 创建一个3x3的CV_8U矩阵
cv::Mat img_8u = cv::Mat::zeros(3, 3, CV_8U);

// 设置矩阵中的像素值
img_8u.at<uchar>(0, 0) = 100;
img_8u.at<uchar>(1, 1) = 200;
img_8u.at<uchar>(2, 2) = 50;

// 打印矩阵中的像素值
std::cout << "CV_8U Matrix:" << std::endl;
for (int i = 0; i < img_8u.rows; ++i) {
    for (int j = 0; j < img_8u.cols; ++j) {
        std::cout << static_cast<int>(img_8u.at<uchar>(i, j)) << " ";
    }
    std::cout << std::endl;
}

// 创建一个3x3的CV_8UC1矩阵
cv::Mat img_8uc1 = cv::Mat::zeros(3, 3, CV_8UC1);

// 设置矩阵中的像素值
img_8uc1.at<uchar>(0, 0) = 150;
img_8uc1.at<uchar>(1, 1) = 50;
img_8uc1.at<uchar>(2, 2) = 255;

// 打印矩阵中的像素值
std::cout << "\nCV_8UC1 Matrix:" << std::endl;
for (int i = 0; i < img_8uc1.rows; ++i) {
    for (int j = 0; j < img_8uc1.cols; ++j) {
        // std::cout << img_8uc1.at<uchar>(i, j) << " ";
        std::cout << static_cast<int>(img_8uc1.at<uchar>(i, j)) << " ";
    }
    std::cout << std::endl;
}

打印的结果:

CV_8U Matrix:
100 0 0
0 200 0
0 0 50

CV_8UC1 Matrix:
150 0 0
0 50 0
0 0 255

4.5  cv::Mat中的cv::Scalar是什么

cv::Scalar
是OpenCV中用于表示多通道数据的数据类型,通常用于表示像素值或颜色信息。它是一个简单的容器,可以存储1到4个数值,分别对应图像中的通道。
cv::Scalar
的构造函数有多个版本,最常用的版本接受1到4个数值,分别对应通道的值。

以下是一些示例:

// 创建一个Scalar对象,表示灰度图像中的像素值
cv::Scalar gray_pixel(128);

// 创建一个Scalar对象,表示RGB图像中的颜色(蓝色)
cv::Scalar blue_color(255, 0, 0);

// 创建一个Scalar对象,表示RGBA图像中的颜色(半透明绿色)
cv::Scalar transparent_green(0, 255, 0, 128);

在处理图像时,
cv::Scalar
可以与
cv::Mat
结合使用,例如设置像素值或提取像素值。例如:

cv::Mat image(100, 100, CV_8UC3, cv::Scalar(0, 0, 255));  // 创建一个红色的图像

cv::Scalar pixel_value = image.at<cv::Vec3b>(50, 50);  // 提取像素值
std::cout << "Pixel value at (50, 50): " << pixel_value << std::endl;

在这个例子中,
cv::Vec3b
表示3通道的
cv::Mat

cv::Scalar
用于存储提取的像素值。
cv::Scalar
的使用使得代码更加简洁,而且可以方便地处理不同通道的数值。

4.6  cv::Mat和unsigned char的区别是什么

cv::Mat

unsigned char
是两种不同的数据类型,它们分别用于不同的目的。

  1. cv::Mat


    • cv::Mat
      是OpenCV库中用于表示图像和矩阵数据的数据类型。
    • 它是一个通用的多维数组类,可以表示单通道或多通道的图像,矩阵,甚至是其他类型的数据。
    • cv::Mat
      有丰富的功能和方法,使得在图像处理和计算机视觉任务中更加方便。
  2. unsigned char


    • unsigned char
      是C++语言中的基本数据类型之一,表示一个8位无符号整数。
    • 它的取值范围是 [0, 255]。
    • 通常用于表示像素值(灰度图像中的每个像素值),其中0表示最暗,255表示最亮。

区别:

  • cv::Mat
    是一个复杂的数据结构,用于存储和处理图像和矩阵数据,提供了许多高级的操作和功能。
  • unsigned char
    是一个基本的数据类型,主要用于表示8位无符号整数,特别适用于存储像素值。

在图像处理中,你通常会使用
cv::Mat
来处理图像数据,而
unsigned char
可能是
cv::Mat
中像素值的底层数据类型。例如,对于灰度图像,
cv::Mat
可能是单通道
CV_8UC1
类型,其中每个像素值为
unsigned char

4.7 cv::Scalar和 cv::Mat的取值范围分别是多少

  1. cv::Scalar


    • cv::Scalar
      是一个简单的数据结构,通常用于表示颜色或像素值。
    • 对于灰度图像,
      cv::Scalar
      中的每个通道的取值范围是 [0, 255]。
    • 对于彩色图像,每个通道的取值范围同样是 [0, 255]。
    • cv::Scalar
      最多可以存储4个数值,分别对应4个通道。
  2. cv::Mat


    • cv::Mat
      是OpenCV中用于表示图像和矩阵的多通道数据结构。
    • 对于图像,通常使用8位无符号整数 (
      CV_8U
      ) 类型,其取值范围是 [0, 255]。
    • 对于其他数据类型,例如
      CV_32F
      (32位浮点数),取值范围可以是任意的,取决于具体的数据类型。

在处理图像时,通常会使用
CV_8U
类型的
cv::Mat
,其中像素值的取值范围是 [0, 255],与
cv::Scalar
中的灰度值或颜色值相匹配。在使用其他数据类型时,需要根据具体的情况来理解像素值的取值范围。

在默认的情况下,对于灰度图像,
cv::Scalar

cv::Mat
的取值范围是相同的,都是 [0, 255]。这是因为
cv::Scalar
通常用于表示像素值,而像素值在灰度图像中是单通道的,每个通道的值都在 [0, 255] 范围内。

例如,对于灰度图像,下面的
cv::Scalar

cv::Mat
表示相同的像素值:

cv::Scalar scalar_value(128);
cv::Mat mat_value(1, 1, CV_8UC1, cv::Scalar(128));

这两个表示都是灰度值为128的像素。然而,需要注意以下几点:

  1. cv::Scalar
    可以用于表示多通道数据: 当处理彩色图像时,
    cv::Scalar
    可以表示多通道的颜色信息,每个通道的值同样在 [0, 255] 范围内。

  2. cv::Mat
    的数据类型可以不同: 对于
    cv::Mat
    ,具体的数据类型可能不仅仅是
    CV_8UC1
    ,还可以是其他类型,例如
    CV_32F
    。在这种情况下,像素值的取值范围将根据具体的数据类型而有所不同。

总体而言,当处理灰度图像时,
cv::Scalar

cv::Mat
的取值范围是相同的。在处理彩色图像或其他数据类型时,需要考虑具体的通道数和数据类型。

4.8 总结:如果超出取值范围,cv::Mat类型还是会进行饱和运算,而uchar只是进行取模运算

当使用
cv::Mat
作为容器表示像素值时,确实会执行饱和运算。这是因为 OpenCV 在处理图像时通常使用
cv::Mat
类型,而这个类提供了丰富的图像处理功能。

对于
unsigned char
,它是C++的基本数据类型,如果超出了255,将执行取模运算。这是因为
unsigned char
是一个循环数据类型,其值会在达到最大值时回绕到0。

让我们通过一个示例来说明这一点:

#include <iostream>

int main() {
    // 使用 cv::Mat 进行饱和运算
    cv::Mat mat_pixel(1, 1, CV_8UC1, cv::Scalar(400));
    std::cout << "cv::Mat pixel value: " << static_cast<int>(mat_pixel.at<uchar>(0, 0)) << std::endl;

    // 使用 unsigned char 进行取模运算
    unsigned char uchar_pixel = 400;
    std::cout << "unsigned char pixel value: " << static_cast<int>(uchar_pixel) << std::endl;

    return 0;
}

在这个例子中,
cv::Mat
类型的像素值为200,但输出将是255,因为它被饱和到了255。而
unsigned char
的像素值也为200,但输出将是200,因为它进行了取模运算,回绕到了0。

cv::Mat pixel value: 255
unsigned char pixel value: 144

预备知识
1.什么是元学习(Meta Learning)?
元学习或者叫做“学会学习”(Learning to learn),它是要“学会如何学习”,即利用以往的知识经验来指导新任务的学习,具有学会学习的能力。由于元学习可帮助模型在少量样本下快速学习,从元学习的使用角度看,人们也称之为少次学习(Few-Shot Learning)。
2.什么是基于度量的元学习(Metric-based meta-learning)?
基于度量的元学习将相似性学习和元学习相结合,学习训练过的相似任务的经验,从而加快新任务的完成。
Guo等人
将注意机制与集成学习方法相结合,形成了基于度量的元学习模型。
标记文档非常少的情况下,如何识别文档中实体之间的语义关系呢?这是少射文档级关系提取(Few-shot document level relation extraction,FSDLRE)的核心问题,它对于解决实际应用中常见的数据稀缺问题具有重要意义。基于度量的元学习是FSDLRE的一种有效框架,它通过构建类原型来进行分类。然而,现有的方法往往难以得到反映准确关系语义的类原型,主要存在以下两个问题:
  1. 为了构建目标关系类型的原型,它们将所有具有该关系的实体对的表示聚合在一起,但这些实体对可能同时具有其他关系,导致原型的混淆。
  2. 它们在所有任务中使用同一组通用的非上述(none-of-the-above,NOTA)原型,忽视了不同任务中目标关系类型对NOTA语义的影响。
为了解决这些问题,本文提出了一种基于关系感知的FSDLRE原型学习方法,它能够增强原型表示的关系语义。本文的方法通过利用关系描述和真实的NOTA实例作为指导,有效地优化了关系原型,并生成了适应不同任务的NOTA原型。在两个FSDLRE基准数据集的多种设置下,本文的方法都显著优于最先进的方法,平均提升了2.61%的F1值。
1 Introduction
文档级关系抽取(DocRE)的定义:识别文档中每对实体之间的关系。
DocRE面临的问题:数据稀缺,数据标注费时费力,并且许多特定领域缺乏带注释的文档。
FSDLRE的定义:在标记文档非常少的情况下,识别文档中实体之间的语义关系。
Popovic和Färber等人(2022)
提出一个基于度量的元学习框架,通过训练一组抽样的FSDLRE任务,学习了一个度量空间。在这个空间里,它能够根据实体对到不同关系类型的原型表示的距离,进行分类。这样,该方法就具备了FSDLRE的通用知识,可以快速适应新的关系类型和新的任务。
图1展示了一个FSDLRE任务的例子,这是一个1-Doc的设置,即只给出了一个带有标注关系实例的支持文档,以及三个目标关系类型:Place of Birth、Work Location、Place of Death。任务的目标是预测查询文档中预先给定的实体对之间的所有目标关系类型的实例,例如(Grace Taylor, Place of Birth, Brisbane)。
现有的FSDLRE的两个问题:
  1. 一个实体对在一个文档中可能有多个关系,如果用支持集中的实体对表示来聚合生成关系原型,那么原型就会混杂了其他无关的关系信息,导致度量空间中的关系难以区分,如图2(a)所示。
  2. 由于大部分查询实体对并不涉及任何目标关系,因此 NOTA也被当作一个类别。考虑到不同任务的目标关系类型有所差异,如果只是用一组可学习的向量作为 NOTA 原型,并且在所有任务中通用,这种“一刀切”的策略可能会使 NOTA 原型偏离理想的 NOTA 语义,从而导致分类的混淆。如图 2(a) 所示,一组通用的 NOTA 原型在任务 1 中看起来还可以,但是在任务 2 中就不太合适。
为了解决FSDLRE中的两个问题,本文提出了一种新颖的关系感知原型学习方法(Relation-Aware Prototype Learning method,RAPL)。首先,对于支持文档中存在关系的每一对实体,利用关系描述中的固有关系语义作为指导,为每一个表达的关系生成一个实例级别的表示,如图2(b)所示。关系原型是通过聚合所有支持关系实例的表示来构建的,从而更好地关注与关系相关的信息。基于实例级别的支持嵌入,本文提出了一种关系加权对比学习方法来进一步优化原型。通过将关系间的相似性纳入对比目标,可以更好地区分语义相近的关系的原型。此外,本文设计了一种任务特定的NOTA原型生成策略。对于每一个任务,自适应地选择支持的NOTA实例,并将它们融合到一组可学习的基础NOTA原型中,生成任务特定的NOTA原型,从而更有效地捕捉每个任务中的NOTA语义。
2 Problem Formulation
FSDLRE是指在

的设定下,从少量的文档中抽取出实体之间的关系。每个FSDLRE任务(task,也称为一个episode)包含

篇支持文档

和一篇查询文档

,并且每篇文档中提到的实体都进行了预注释。对于每篇支持文档

,还给出了一个三元组集合

,其中包含了文档中所有有效的关系三元组

。这里



分别是关系实例的头实体和尾实体,而

是一种关系类型,

是本任务要抽取的关系类型集合。支持文档的标注是完备的,也就是说,任何没有对应关系类型的实体对都被视为NOTA。在给定这些输入的情况下,FSDLRE任务的目标是预测查询文档

中的三元组集合

,它包含了

中所有属于

中关系类型的有效三元组。
本文的方法基于典型的元学习框架。在训练阶段,从一个训练文档语料库

中采样出支持文档和查询文档,构成一系列的训练任务。每个训练任务的

集合是

的一个子集,

是元训练阶段的关系类型集合。模型的目的是从这些训练任务中学习通用的知识,以便能够更好地适应新的任务。
在测试阶段,模型在一个由测试文档语料库

中采样出的测试任务组上进行评估,



是互斥的。每个测试任务的

集合是

的一个子集,

是元测试阶段的关系类型集合,它和

也是互斥的。
3 Methodology
3.1 Document and Entity Encoding
本文使用预训练的语言模型
(Devlin等人,2019)
作为文档编码器,对每个支持文档或查询文档进行上下文相关的编码。
对于任意文档

,首先在实体提及的首尾位置插入特殊标记“∗”,以便识别实体的边界。然后将文档送入编码器,得到标记的上下文嵌入

和交叉标记注意力

:
其中





中标记的个数,

是编码器的输出维度,

是编码器最后一层的注意力头的平均值。用实体提及前的“*”标记的嵌入作为对应的提及嵌入。对于文档中的每个实体

,它有

个提及

,对它们的嵌入应用logsumexp池化
(Jia等人, 2019)
,得到实体嵌入


,其中



的第

个提及的嵌入。
3.2 Relation-Aware Relation Prototype Learning
在给定的一个任务中,本文的目标是为每种目标关系类型获得一个原型表示,能够更好地捕捉相应的关系语义。为此,本文首先提出了基于实例级支持嵌入(instance-level support
embeddings)的关系原型构建方法,使每个原型能够更多地关注支持文档中与关系相关的信息。然后,提出了一种实例级关系加权对比学习方法(instance-level relation weighted contrastive learning method),进一步优化了关系原型。
3.2.1 Instance-Based Prototype Construction
给定一个支持文档中的关系实例

,首先计算一个实体对级别的重要性分布

,覆盖文档中的所有标记,以捕捉与实体对

相关的上下文
(Zhou等人,2021)
其中,

是一个实体级注意力,通过对每个实体

的每个提及

前的标记“∗”的提及级注意力

取平均得到:

,对于

也同样如此,而

是哈达玛积。
同时,计算一个关系级注意力分布

,覆盖所有的标记,以捕捉与关系

相关的上下文。使用另一个预训练的语言模型作为关系编码器,并将关系

的名称和描述拼接成一个序列,然后将序列输入到编码器中。将

标记的输出嵌入作为关系嵌入

并计算关系级注意力

为:
其中,

是可学习参数。
基于



,进一步计算所有标记上的实例级注意力分布

,以捕获与实例相关的上下文。具体来说,在

的第

维上的值

可以由下式求得:
其中,

返回

中最大的

元素的索引,

是一个超参数,

是指示函数。接着对

进行归一化,以恢复注意力分布。在这里,不使用

作为实例级注意力,因为对于一个实例,关系是基于实体对来表达的。直接相乘可能会错误地增加与实体对无关的标记的权重。相反,本文利用关系级注意力来放大与实例最相关的上下文的实体对级权重。
然后,计算一个实例上下文嵌入

并将其融合到头实体和尾实体的嵌入中,得到具有实例感知的实体表示
其中,

为可学习参数。

的实例表示通过连接头和尾实体表示获得,表示为

最后,将支持文档中关系

的所有实例集合记为

,通过对

中关系实例的表示取平均值,计算关系原型

:
3.2.2 Contrastive-Based Prototype Refining
通过将关系原型的构建转化为实例级别,每个原型可以更好地关注与关系相关的支持信息。然而,由于文档上下文的复杂性,同一关系的不同实例可能表现出不同的关系表达模式,使得原型难以捕捉共同的关系语义。另外,有限的支持实例也使得语义接近的关系的原型难以捕捉它们的深层语义差异。因此,本文提出了一种关系加权对比学习方法,进一步优化关系原型。
给定一个任务,用

表示支持文档中所有关系实例的集合,即

。对于一个关系实例

,定义集合


,它包含了支持集中所有其他也表达了关系

的实例,以及集合

,它简单地包含了支持集中所有其他的实例。然后将关系间的相似性融入到一个对比目标中,并定义关系加权对比损失

为:
其中

是一个超参数,

表示余弦相似度。这种对比损失考虑了两个方面:
  1. 首先,以前的方法很难与对比目标相结合,因为它们只能得到成对的支持向量。实体对的多标签性质使得合理地定义正负对很困难。
  2. 其次,通过引入关系间的相似性,这种对比损失更加注重将语义上接近的关系的实例向量分开,从而有助于更好地区分相应的关系原型。
3.3 Relation-Aware NOTA Prototype Learning
由于大多数查询实体对没有任何目标关系,所以NOTA也被视为一个类别。现有的方法通常学习一组通用的NOTA原型,用于所有的任务,但这在某些任务中可能不是最优的,因为NOTA的语义在不同的目标关系类型的任务中是不同的。为此,本文提出了一种任务特定的NOTA原型生成策略,以更好地捕捉每个任务中的NOTA语义。具体来说,本文首先引入一组可学习的向量

,其中

是一个超参数。与之前的工作不同,本文不直接将这组向量作为NOTA原型,而是将它们视为需要在每个任务中进一步修正的基础NOTA原型。由于支持文档的标注是完整的,可以得到一个支持NOTA分布,它隐含地表达了NOTA的语义。因此,可以利用支持NOTA实例来捕捉每个特定任务中的NOTA语义。对于一个支持NOTA实例

,使用等式2作为实例级别的注意力,并根据等式6~8得到实例表示


。将所有的支持NOTA实例的集合记为

,为每个基础NOTA原型

自适应地选择一个NOTA实例:
这个等式找到了一个与基础NOTA原型最接近,而与关系原型最远的NOTA实例。然后,将它与

融合,得到最终的NOTA原型

其中

是一个超参数。通过这种方式,可以获得一组特定于任务的NOTA原型,这些原型不仅包含了元学习的一般知识,而且隐含地捕获了每个特定任务中的NOTA语义。
3.4 Training Objective
给定查询文档中的一个实体对

,使用等式2作为实体对级别的注意力,并采用类似于等式6~8的方法得到实体对的表示


。对于每个目标关系类型

,计算

的概率为:
其中

。然后,将查询文档中的所有实体对的集合记为

,计算分类损失为:
如果

之间存在关系

,则

;否则

。总的损失定义为:
其中

是一个超参数。在推理过程中,如果

,就提取查询文档中的关系实例

4 Experiments
4.1 Benchmarks and Evaluation Metric
使用的数据集:
  1. FREDo
    :包含两种主要的任务,一种是同域任务,一种是跨域任务。对于同域任务,训练集和测试集的文档语料来自同一个领域。对于跨域任务,测试集的文档语料来自不同的领域,导致训练集和测试集之间的文本风格、文档主题和关系类型有较大的差异。每种任务都有一个1-Doc 和一个3-Doc 的子任务,用来衡量模型的可扩展性。FREDo 使用 DocRED的训练集作为训练和开发的文档语料,使用 DocRED 的开发集作为同域测试的文档语料,使用
    SciERC
    的整个集合作为跨域测试的文档语料。DocRED的关系类型集合被划分为三个不相交的集合,用于FREDo的训练 (62)、开发 (16) 和同域测试 (18)。FREDo为同域评估采样了15k个情景,为跨域评估采样了3k个情景。
  2. ReFREDo:是FREDo的修订版。由于FREDo使用了DocRED作为底层的文档语料,而DocRED存在不完整的标注问题,因此FREDo构建的情景也可能存在这些标注错误。为了解决这个问题,本文构建了 ReFREDo 作为 FREDo 的修订版本。在ReFREDo中,用Re-DocRED替换了训练、开发和同域测试的文档语料,Re-DocRED是DocRED的修订版本,具有更完整的标注。然后沿用 FREDo的关系类型划分,为同域评估采样了15k个情景。跨域测试的情景与 FREDo 保持一致。还遵循
    Popovic 和 Färber (2022)
    的方法,计算了 ReFREDo 中测试情景的平均类别数

    和每个类别的平均支持实例数

    ,如表 1 所示。

Popovic 和 Färber (2022)
一致,使用宏 F1 来评估整体性能。
4.2 Baselines
本文的方法与四种基于FREDo的基线方法进行比较:

  1. 是一个初始的基线方法,它使用预训练的语言模型而不进行微调。

  2. 是一个基于度量的方法,它建立在最先进的有监督的 DocRE 方法和少样本句子级关系抽取方法的基础上。

  3. 在推理时使用所有的支持实体对,而不是将它们的嵌入平均成一个原型,以提高

    在跨域任务上的性能。

  4. 在训练时使用 NOTA 实例作为额外的 NOTA 原型,并且在推理时只使用 NOTA 实例,以进一步提高

    在跨域任务上的性能。
此外,本文还评估了有监督的 DocRE 模型,它通过在整个训练语料上学习并在支持集上微调。在这里,选择
KDDocRE
作为最先进的公开可用的有监督的 DocRE 方法。为了公平的比较,遵循
Popovic and Färber (2022)
的方法,使用 BERT-base作为本文方法的编码器。
4.3 Main Results
表 2 显示了RAPL在 FREDo 和 ReFREDo 上的主要结果。
从实验结果可知:
  1. RAPL 相比于基线方法,在两个基准数据集上都取得了显著更好的平均结果(在 FREDo 上的 F1 值为 2.50%,在 ReFREDo 上的 F1 值为 2.72%)。
  2. RAPL 在每个任务设置中都一致地超过了最好的基线方法(不同任务设置中的最好基线方法可能不同),使得它比以前的方法更具通用性。
  3. RAPL 在同域任务上相比于跨域任务有更多的提升。这进一步反映了跨域设置所带来的更大的挑战。
  4. RAPL 在 3-Doc 任务上的性能一致地高于 1-Doc 任务上的性能,而这一点并不总是能够被最好的基线方法保证,证明了 RAPL 的更好的可扩展性。
  5. 所有方法在 ReFREDo 上的同域性能显著高于在 FREDo 上的同域性能,而这一性能差距在跨域设置下并没有体现在两个基准数据集之间。这表明,一个更高质量的训练集可能并不能有效地解决域适应问题。
  6. KDDocRE 的性能不令人满意,表明有监督的 DocRE 方法可能不能很好地适应少样本场景。
4.4 Ablation Study
本文在ReFREDo上进行了消融实验,以探究RAPL每个模块的影响。具体来说,
  1. 对于“−RCL”,去掉了基于关系加权的对比学习方法。
  2. 对于“−IBPC−RCL”,进一步去掉了基于实例的关系原型构建方法,并且只用与查询实体对相同的方式获取每个支持实体对的对级嵌入。
  3. 对于“−IBPC−RCL+SCL”,在“−IBPC−RCL”模型中加入了一个有监督的对比学习目标(Khosla et al., 2020; Gunel et al., 2021),其中把那些共享相同关系的实体对作为正样本,否则作为负样本。
  4. 对于“−TNPG”,去掉了任务特定的 NOTA 原型生成策略,并且直接把基础的 NOTA 原型作为最终的 NOTA 原型。
平均结果如表 3 所示。可以观察到,与 RAPL 相比,“−RCL”和“−TNPG”模型的性能有不同程度的下降,“−IBPC−RCL”模型的性能甚至比“−RCL”还要差,这证明了每个模块的有效性。此外,在对级上整合对比目标并没有带来显著的改进,这表明了学习实例级支持嵌入的重要性。
4.5 Analyses and Discussions
Effect of Hyperparameters.
本文研究了不同超参数对RAPL性能的影响。本文在 ReFREDo 数据集上的 3-Doc 任务中进行了实验。如图 4 所示,可以观察到:
  1. 对于控制实例级注意力导出的超参数

    ,针对领域内任务的最佳值比跨领域任务的大,这可能与领域内语料中的文档更长有关。
  2. 在对比目标中的温度超参数

    (约 0.4)对于与分类目标的协同以及整体模型性能至关重要。
  3. 盲目地降低超参数

    以增加支持 NOTA 实例在 NOTA 原型中的权重可能会对 NOTA 原型的学习产生负面影响。
  4. 与其他超参数相比,模型对于一定范围内的 NOTA 原型数量

    不是很敏感。
Support Embeddings Visualization.
为了直观地展示RAPL的优势,本文从ReFREDo数据集的领域内3-Doc测试语料中选择了三种语义相近的关系类型,并为每种关系类型采样了十个支持实例,然后使用

进行可视化 (Van der Maaten和Hinton, 2008),如图 5 所示。除了消融实验中的两种模型变体,本文还尝试了

,它用有监督的对比损失 (Khosla等人, 2020; Gunel等人, 2021) 替换了实例级的关系加权对比损失。由于一些实体对同时表达了“部分”和“成员”关系,或者“部分”和“子类”关系,本文只在图 5(a) 中可视化了

的“部分”关系。可以观察到,由 RAPL−RCL 学习的支持实例嵌入改善了

学习的支持对嵌入,证明了实例级嵌入对于关系原型构建的有效性。此外,尽管引入实例级的有监督对比目标形成了更紧凑的聚类,但三种关系类型之间的区分度仍然不足。如图 5(d) 所示,本文提出的关系加权对比学习方法更好地区分了三种关系类型。
Performance vs. NOTA Rate of Episodes.
本文进一步探索了任务特定的 NOTA 原型生成策略对性能提升的影响。根据每个测试样本的 NOTA 比率,即查询文档中 NOTA 实体对与总实体对数的比例,将 ReFREDo 数据集的领域内 3-Doc 测试样本划分为不相交的子集。建立了四个子集,分别对应 NOTA 比率在 [0%,95%),[95%, 97%),[97%, 99%) 和 [99%, 100%] 区间内的情况。然后在每个子集上评估了使用或不使用任务特定的 NOTA 原型生成策略训练的模型。实验结果如表 4 所示。可以观察到,任务特定的 NOTA 原型生成策略在每个子集上都带来了改进。更重要的是,随着 NOTA 比率的增加,性能提升也越大。这表明,任务特定的 NOTA 原型生成策略有助于捕捉 NOTA 语义,从而生成更好的 NOTA 表示,尤其是在那些涉及更多 NOTA 查询对的测试样本中。
Performance vs. Number of Support Relation Instances.
本文还分析了支持关系实例数对总体性能的影响。在 ReFREDo 基准数据集的领域内 3-Doc 任务上进行了实验。对于每个测试样本中的每种关系类型,计算了该样本中该关系类型的支持实例数。这里将支持实例数划分为 10 个类别,其中前 9 个类别对应于 1 到 9,最后一个类别对应于支持实例数大于或等于 10 的情况。然后在这些类别上评估了 RAPL 方法的性能,如图 6 所示。可以观察到,RAPL 的性能随着支持关系实例数的增加而呈现出上升趋势,但在某些点也出现了波动。这表明,所提出的方法具有一定的可扩展性,但性能可能不是完全正相关于支持关系实例数。
Preliminary Exploration of LLM for FSDLRE.
近期,大型语言模型(LLM)通过在上下文学习的方式,在许多少样本任务上取得了令人鼓舞的结果。也有一些工作专注于利用 LLM 解决少样本信息抽取问题。然而,大多数研究主要针对句子级任务。因此,本文使用
gpt-3.5-turbo3
进行了一个初步实验,探索 LLM 在 FSDLRE 任务上的性能。由于输入长度的限制,本文只在1-Doc 设置下进行实验。本文从 ReFREDo 的领域内测试样本中随机选择了 1000 个样本,并设计了一个包含任务描述、示例和查询的在上下文学习提示模板(详见附录 C)。实验结果显示,gpt-3.5-turbo 只达到了 12.98% 的宏 F1,甚至低于一些基线方法。虽然这个测试可能无法充分反映 LLM 的能力,但本文认为 FSDLRE 仍然是一个具有挑战性的问题,即使在 LLM 的时代。
5 Related Work
Sentence-Level Relation Extraction.
......
Document-Level Relation Extraction.
大多数现有的 DocRE 研究都基于数据驱动的监督学习场景,通常可以分为基于图的方法和基于序列的方法。基于图的方法通常通过图结构来抽象文档,并用图神经网络进行推理。基于序列的方法用纯粹的Transformer架构来编码长距离的上下文依赖。这两类方法在 DocRE 任务上都取得了令人印象深刻的结果。然而,这些方法都依赖于大规模的标注文档,这使得它们难以适应低资源的场景。
Few-Shot Document-Level Relation Extraction.
为了解决现实中 DocRE 场景中普遍存在的数据稀缺问题,Popovic 和 Färber(2022)将 DocRE 任务转化为少样本学习任务。为了完成这个任务,他们提出了多种基于度量的模型,这些模型都是基于最先进的监督式 DocRE 方法和少样本句子级关系抽取方法构建的,目的是适应不同的任务设置。而对于一个有效的基于度量的 FSDLRE 方法,每个类别的原型应该准确地捕捉相应的关系语义。然而,这对于现有的方法来说是具有挑战性的,因为他们的关系原型学习策略是粗粒度的,而且他们的“一刀切”的 NOTA 原型学习策略也不合理。在这项工作中,本文提出了一种关系感知的原型学习方法,以更好地捕捉原型表示中的关系语义。
6 Conclusion
本文提出了RAPL,这是一种 用于FSDLRE的新颖的关系感知原型学习方法。本文将关系原型的构建重新定义为实例级别,并进一步提出了一种关系加权对比学习方法,来共同优化关系原型。此外,本文设计了一种任务特定的NOTA原型生成策略,以更好地捕捉每个任务中的NOTA语义。实验结果和进一步的分析证明了本文方法的优越性和每个组件的有效性。在未来的工作中,本文想要将该方法迁移到其他少样本文档级信息抽取任务,例如少样本文档级事件论元抽取,它与FSDLRE具有相似的任务结构。
Limitations
首先,引入关系编码器和寻找支持NOTA实例的过程增加了内存和时间的开销。其次,假设实体信息应该被指定可能影响方法的鲁棒性
(Liu等人,2022b)
。在有监督的场景中,一些最近的DocRE研究探索了联合实体和关系抽取,以避免这个假设(
Eberts和Ulges,2021

Xu和Choi,2022

Zhang等人,2023
)。本文认为,在少样本场景中,研究端到端的DocRE是有益的,RAPL方法可能为未来的工作提供一些启示。最后,RAPL在跨领域任务上的性能提升低于在领域内任务上的性能提升。一个未来的研究方向是探索提高跨领域任务性能的技术,例如数据增强
(Hu等人,2023c)
和结构化知识引导(
Liu等人,2022a

Ma等人,2023a
)。

文编|JavaBuild

哈喽,大家好呀!我是JavaBuild,以后可以喊我鸟哥!俺滴座右铭是不在沉默中爆发,就在沉默中灭亡,一起加油学习,珍惜现在来之不易的学习时光吧,等工作之后,你就会发现,想学习真的需要挤时间,厚积薄发啦!

我们知道Java是面向对象的静态型编程语言,在Java的世界里万物皆对象。但我认为是万物皆数据,世界由各种各样数据构建起来,我们通过程序去实现数据的增删改查、转入转出、加减乘除等等,不同语言的实现方式殊途同归。
由此可见,数据对于程序语言的重要性,而在Java中用来规范数据的标准我们将其称之为“
数据类型
”,这便是我们今天的Topic!
在下图中我们将Java中的数据类型分为三个部分:
基本数据类型

包装类型

引用数据类型

基本数据类型

在Java中“boolean、char、byte、short、int、long、float 和 double”构建起了数据结构的基础,非常重要,也是很多公司面试的高频考点,所以,为了方便记忆,鸟哥整理了一份表格如下:

类型名称 位数 字节 默认值 取值范围 包装类 缓存区间
byte 8 1 0 -128 ~ 127 Byte -128~127
short 16 2 0 -32768 (-2^15) ~ 32767 (2^15-1) Short -128~127
int 32 4 0 -2147483648 ~ 2147483647 Integer -128~127
long 64 8 0L -2^63 ~2^63 - 1 Long -128~127
char 16 2 '\u0000' 0~65535 Character 0 ~127
float 32 4 0f 1.4e-45 ~ 3.4e+38 Float
double 64 8 0d 4.9e-324~1.798e+308 Double
boolean 1 false true(1),false(0) Boolean

字节与位的关系

在计算机的物理存储中,一条电路线被称之为1位,二进制识别中为0(低电平)或1(高电平),英文中用bit表示,而8个bit组成一个字节,英文为Byte

布尔类型的说明

对于 boolean,官方文档未明确定义,它依赖于 JVM 厂商的具体实现。逻辑上理解是占用 1 位,但是实际中会考虑计算机高效存储因素。

基本数据类型之间的转换规则

基本数据类型之间也存在着转换关系,往往发生在表达式计算的过程中,而这种转换根据不同场景分为:
自动类型转换&强制类型转换
自动类型转换:Java编译器无需显示处理,一般由等级低的数据类型向等级高的数据类型转换,如int -> long。很明显,int所能存储的数据必定是long的子集,不存在数据丢失问题。

等级由低到高
byte -> short -> int -> long -> float -> double
char -> int -> long -> float -> double
int a = 3;
double b = 1.5;
// 自动类型转换:a 被转换为 double 类型
double result = a * b;
System.out.println("结果: " + result); // 输出:结果: 4.5

强制类型转换:由高等级数据转为低等级数据时往往存在强制类型转换,这时候Java编译器认为存在隐患,需要程序员介入,显示的处理强转,潜在风险是数据丢失或精度丢失。

由左到右需要强转
double -> float -> long -> int -> char -> short -> byte
double c = 10.1;
// 强制类型转换:将 double 类型转换为 int 类型,精度丢失
int d = (int) c;
System.out.println("整数值: " + d); // 输出:整数值: 10

转换规则如下

= 右边先自动转换成表达式中最高级的数据类型,再进行运算。整型经过运算会自动转化最低 int 级别,如两个 char 类型的相加,得到的是一个 int 类型的数值。
= 左边数据类型级别 大于 右边数据类型级别,右边会自动升级
= 左边数据类型级别 小于 右边数据类型级别,需要强制转换右边数据类型
char 与 short,char 与 byte 之间需要强转,因为 char 是无符号类型

包装类型

这八种基本类型都有对应的包装类分别为:Byte、Short、Integer、Long、Float、Double、Character、Boolean 。
因为Java中一切皆对象,基本数据类型无法满足这个大口号,比如泛型、序列化、类型转换、高频数据区间的缓存等,故为了弥补,便诞生了8种基本数据类型对应的包装类型。

包装类型与基本数据类型差异

  • 使用场景:
    在Java中除了一些常量和局部变量的定义会用到基础数据类型外,绝大部分情况下均采用包装类型,如方法参数,对象属性等,且基本数据类型不能用于泛型,包装类型可以!
  • 默认值:
    包装类型比基本类型多了一个非功能值:null,在不做任何赋值的情况下,包装类型的默认就是null,而基本数据类型都有相应的默认值,见上面表格。
  • 所占内存
    因为包装类型是对象,会有一些对象头等信息,所以占用空间上要大于基本数据类型。
  • 比较方式
    基本类型采用 == 号比较,比较的是值,而对于包装类型来说,==比较的其实是对象的内存地址,对象值的比较需要通过equals()方法完成。

[注意]:
很多同学都以为基本数据类型存在栈中,包装类型作为对象存储在堆中,这个观点是有失偏颇的,如果基础数据类型的成员变量在没有被static关键字修饰的情况下,是存在的堆中的,只有局部变量被存在栈的局部变量表中!而全部对象都存在堆中,也是个不完整的答案,这里涉及到HotSpot中的逃逸分析,等讲到JVM时再展开说吧。

自动装箱与拆箱

在Java中不仅仅基本类型之间存在着转换,基本数据类型与包装类型之间同样存在着转换,在JDK1.5之前是不支持自动装箱与拆箱的,所以那时候需要通过显示的方法调用来实现转换,而JDK1.5开始,自动化程度提升了。
装箱
:基本类型转变为包装器类型的过程。
拆箱
:包装器类型转变为基本类型的过程。

//JDK1.5之前是不支持自动装箱和自动拆箱的,定义Integer对象,必须
Integer i = new Integer(8);
 
//JDK1.5开始,提供了自动装箱的功能,定义Integer对象可以这样
Integer i = 8;
int n = i;//自动拆箱

实现原理

装箱是通过调用包装器类的 valueOf 方法实现的
拆箱是通过调用包装器类的 xxxValue 方法实现的,xxx代表对应的基本数据类型。
如int装箱的时候自动调用Integer的valueOf(int)方法;Integer拆箱的时候自动调用Integer的intValue方法。

【需要注意的问题点】:
1、整型的包装类 valueOf 方法返回对象时,在常用的取值范围内,会返回缓存对象。
2、浮点型的包装类 valueOf 方法返回新的对象。
3、布尔型的包装类 valueOf 方法 Boolean类的静态常量 TRUE | FALSE。

Integer i1 = 100;
Integer i2 = 100;
Integer i3 = 200;
Integer i4 = 200;

System.out.println(i1 == i2);//true
System.out.println(i3 == i4);//false
        
Double d1 = 100.0;
Double d2 = 100.0;
Double d3 = 200.0;
Double d4 = 200.0;
System.out.println(d1 == d2);//false
System.out.println(d3 == d4);//false
        
Boolean b1 = false;
Boolean b2 = false;
Boolean b3 = true;
Boolean b4 = true;
System.out.println(b1 == b2);//true
System.out.println(b3 == b4);//true

以上代码中,我们已Integer为例展开解释一下,基本数据类型的包装类除了 Float 和 Double 之外,其他六个包装器类(Byte、Short、Integer、Long、Character、Boolean)都有常量缓存池。而Integer的缓存区间是-128~127。
我们去看一下Integer类的缓存源码

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}
private static class IntegerCache {
    static final int low = -128;
    static final int high;
    static {
        // high value may be configured by property
        int h = 127;
    }
}

IntegerCache 这个静态内部类中设置了缓存区间,当我们通过valueOf()方法获取Integer对象时,会先去找该整数是否在缓存池中,有则直接返回,没有则新建并存入缓存池。
这就解释了为什么第一个 == 号结果是true,而第二个为false,因为超出了缓存区间,每次都新建一个对象,而 == 号又是比较对象地址,对于两个不同的对象,地址肯定不一样啦。

引用数据类型

Java的数据类型除了8种基本数据类型和对应的包装类型外,还有一个分类为引用数据类型,在文章开头的树形图中已经分好,引用类型分为:数组,类和接口。
那为什么叫他引用数据类型呢?在创建引用数据类型时,会在栈上给其引用句柄,分配一块内存,然后对象的信息存储在堆上,在程序调用的时候,通过栈上的引用句柄指向堆中的对象,从而获取想要的数据。
因数组,类,接口都包含着太多内容,在后续的博客中会陆续详解,所以本文略做介绍,粗略感受一下。
数组:

int [] arrays = {1,2,3};
System.out.println(arrays);
// 打印结果:[I@2d209079

打印结果中的一串内容,世界上是arrays的对象首地址,要想看到结果,需要调用java.lang.Object 类的 toString() 方法。
类:

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];
}

String是一个类,也是字符串的代表,所以它也是引用数据类型
接口:

List<String> list = new ArrayList<>();
System.out.println(list);

List 是一个非常典型的接口,属于Java的一种集合,存储的元素是有序的、可重复的。

【注意】

1、包装类可以实现基本类型和字符串之间的转换,字符串转基本类型:parseXXX(String s);基本类型转字符串:String.valueOf(基本类型)。
2、引用数据类型的默认值为 null,包括数组和接口。
3、char a = 'h'char :单引号,String a = "hello" :双引号。

概念是学习的基础。在学习JS中的文件操作之前,先把文件相关的各种概念搞清楚,很有好处。

1. 二进制:

计算机硬件仅能处理和存储二进制数据,所以不管是你正在写的代码,还是你硬盘里的小姐姐,都是以二进制的形式存储于电脑的内存和硬盘里的。

2. 编码规则:

二进制计算机看得懂,我们看不懂怎么办啊?我们能看懂的是文字、图片、视频。编码规则,就是二者之间的桥梁。

文本的编码规则很简单,就是二进制(先转成16进制)和我们能看懂的字符一一对应,图片和视频的编码要复杂一些。但可以简单理解为:二进制数据经过不同的编码规则,编码成不同的文件格式。

3. 字符集:

字符集是和编码规则密切相关的一个概念,它定义了一组字符以及这些字符对应的二进制编码。

比如Unicode字符集,包括UTF-8、UTF-16、UTF-32几种编码规则。

字符集主要应用于文本数据,相比之下,编码规则这个概念更为广泛,所有类型的文件都有自己的编码规则,不仅限于文本文件。

4. 文本文件:

文本文件,是指采用ASCII、UTF-8等文本编码规则创建的文件,其中的内容主要是人类可直接阅读的文字信息。

5. 二进制文件:

除了文本文件之外,其他类型的文件,如图像、音频、视频和可执行程序等,通常被统称为二进制文件,它们的编码规则相对复杂且非文本形式。

6. 文件类型:

文件类型指的是根据文件的内容或用途进行的大类别划分。例如,文本文件、图像文件、音频文件、视频文件、文档文件、可执行程序等。

7. 文件格式:

相较于文件类型,文件格式是一个更具体的术语,每个大类别的文件类型下可能包含多种不同的文件格式,例如文本文件包括txt、doc、xml等多种具体格式。

8. 文件后缀名:

文件后缀名并不一定是文件的真实类型,我们都知道后缀名是可以随便改的。在Linux系统中,甚至没有文件后缀名。

它的作用,只是让操作系统用默认的程序打开这个文件。在控制面板中,我们知道有一个配置是,每种文件后缀名的默认打开应用。

在这方面,有点类似于浏览器根据响应内容的mime类型,来对响应做不同的处理。

9. 文件的编码:

一个文件的编码,指的是创建这个文件时所使用的编码规则,关键词是“创建”。

10. 用不同的编码打开文件:

只有使用创建文件时采用的编码规则,才能正确打开文件。当我们用其他的编码规则试图打开文件时,将会无法打开,或者看到乱码。

这个过程中,底层的二进制数据保持不变,变化的是:因为编码规则的改变,我们所看到的内容。

11. 文件格式转换:

这个过程中,和上面是相反的。我们看到的内容保持不变,变化的是:因为编码规则的改变,底层的二进制数据不一样了。

比如我们把jpg文件转换为png,我们看到的内容是一样的,但底层的二进制数据就完全变了。

12. 流:

流这个概念,很抽象。流是一种传输数据的方式,快速连续的传输数据,允许数据分段传输。与流相对的,是一次性传输整个数据块(block)。

你可以理解为把一只大象装冰箱,和把大象煮成肉汤用一根管子流入冰箱。

我们对流比较形象的体验包括:服务器想返回一个大的文件,如果直接把文件返回,文件就会全部装入内存,可能导致内存被占满。但如果采用流的方式传输,只占用很少的内存就可以了。

在浏览器端呢,如果不采用流的方式接收文件,就要等文件全部传输完成才能开始下载,比如视频全部下载完成才能开始观看。使用流的方式接收,就可以边传输边下载,边下载边观看了。

流的诞生,首先是因为硬件的发展,开始支持这种数据传输的方式,服务器、浏览器、网络设备具备了传输流、处理流的能力。编程语言又对流进行了高级抽象,提供了流相关的API。它最早出现于Unix系统的I/O模型,然后被用于TCP/IP协议,现在,流被广泛的应用于网络通信、文件I/O、数据库连接、web开发等,可以说流无处不在。

一个最简单的流,包括数据来源(字符串、文件等)和数据的去向。打开流,数据开始传输,传输完成后,再手动关闭流。当然,大部分编程框架为了方便,都提供了传输完成后自动关闭流的功能。

那么在平时开发中,我们和流又有什么关系呢?

服务器端:在服务器端发送和接收数据,是否以流的方式进行,是我们自己选择的。后端语言都提供流相关的API,比如nodejs的Stream类。

网络传输中:数据都是以字节流的形式传输的。

浏览器端:浏览器会自动的以流的形式发送和接收数据,开发者无需显式操作。这也是我们前端在开发中,感受不到流的存在,很少使用流的原因。

需要注意的是,不管服务器是否采用流的方式发送数据,在网络传输阶段,都是字节流。浏览器也不关心服务器是否使用流,因为经过网络传输,到了浏览器这儿,接收到的都是流。

尽管如此,对于服务器来说,显式的以流的方式接收和发送数据,仍然是很有好处的。

13. mime类型:

mime类型是一种标准化的互联网标准,用于描述在网络上传输的数据类型。最初是为了扩展电子邮件以支持多种数据类型而设计的,但现在广泛应用于HTTP协议中。

mime类型由两部分组成:主要类型和子类型,中间用斜线分隔。例如:text/plain、image/jpeg、application/pdf等。

mime类型和文件格式大部分时候一一对应。你可以理解,在我们电脑里是文件格式,在HTTP协议中是mime类型。

14. 一次HTTP请求:

一次完整的HTTP请求,是指从浏览器发送请求,到浏览器接收完所有数据的全过程。如果请求的是一个视频,那么直到浏览器把这个视频下载完成,这次HTTP请求才算结束。

也就是说,一个HTTP请求完全可能持续很长时间,并不都是我们大部分时候看到的很快完成的。当然,得益于接收到的是流数据,浏览器可以边下载边播放。